mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 08:11:26 +00:00
TUI/Gateway: fix pi streaming + tool routing + model display + msg updating (#8432)
* TUI/Gateway: fix pi streaming + tool routing * Tests: clarify verbose tool output expectation * fix: avoid seq gaps for targeted tool events (#8432) (thanks @gumadeiras)
This commit is contained in:
committed by
GitHub
parent
a42e3cb78a
commit
38e6da1fe0
@@ -163,6 +163,7 @@ export function handleMessageUpdate(
|
||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
||||
},
|
||||
});
|
||||
ctx.state.emittedAssistantUpdate = true;
|
||||
if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) {
|
||||
void ctx.params.onPartialReply({
|
||||
text: cleanedText,
|
||||
@@ -215,6 +216,44 @@ export function handleMessageEnd(
|
||||
? extractAssistantThinking(assistantMessage) || extractThinkingFromTaggedText(rawText)
|
||||
: "";
|
||||
const formattedReasoning = rawThinking ? formatReasoningMessage(rawThinking) : "";
|
||||
const trimmedText = text.trim();
|
||||
const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null;
|
||||
let cleanedText = parsedText?.text ?? "";
|
||||
let mediaUrls = parsedText?.mediaUrls;
|
||||
let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
|
||||
|
||||
if (!cleanedText && !hasMedia) {
|
||||
const rawTrimmed = rawText.trim();
|
||||
const rawStrippedFinal = rawTrimmed.replace(/<\s*\/?\s*final\s*>/gi, "").trim();
|
||||
const rawCandidate = rawStrippedFinal || rawTrimmed;
|
||||
if (rawCandidate) {
|
||||
const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate));
|
||||
cleanedText = parsedFallback.text ?? rawCandidate;
|
||||
mediaUrls = parsedFallback.mediaUrls;
|
||||
hasMedia = Boolean(mediaUrls && mediaUrls.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) {
|
||||
emitAgentEvent({
|
||||
runId: ctx.params.runId,
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
delta: cleanedText,
|
||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
||||
},
|
||||
});
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
delta: cleanedText,
|
||||
mediaUrls: hasMedia ? mediaUrls : undefined,
|
||||
},
|
||||
});
|
||||
ctx.state.emittedAssistantUpdate = true;
|
||||
}
|
||||
|
||||
const addedDuringMessage = ctx.state.assistantTexts.length > ctx.state.assistantTextBaseline;
|
||||
const chunkerHasBuffered = ctx.blockChunker?.hasBuffered() ?? false;
|
||||
|
||||
@@ -39,6 +39,7 @@ export type EmbeddedPiSubscribeState = {
|
||||
partialBlockState: { thinking: boolean; final: boolean; inlineCode: InlineCodeState };
|
||||
lastStreamedAssistant?: string;
|
||||
lastStreamedAssistantCleaned?: string;
|
||||
emittedAssistantUpdate: boolean;
|
||||
lastStreamedReasoning?: string;
|
||||
lastBlockReplyText?: string;
|
||||
assistantMessageIndex: number;
|
||||
|
||||
@@ -62,6 +62,39 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(onPartialReply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("emits agent events on message_end even without <final> tags", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onAgentEvent = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||
runId: "run",
|
||||
enforceFinalTag: true,
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_start", message: assistantMessage });
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
const payloads = onAgentEvent.mock.calls
|
||||
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
|
||||
.filter((value): value is Record<string, unknown> => Boolean(value));
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.text).toBe("Hello world");
|
||||
expect(payloads[0]?.delta).toBe("Hello world");
|
||||
});
|
||||
it("does not require <final> when enforcement is off", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -185,6 +185,71 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
expect(payloads[1]?.delta).toBe(" world");
|
||||
});
|
||||
|
||||
it("emits agent events on message_end for non-streaming assistant text", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onAgentEvent = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||
runId: "run",
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_start", message: assistantMessage });
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
const payloads = onAgentEvent.mock.calls
|
||||
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
|
||||
.filter((value): value is Record<string, unknown> => Boolean(value));
|
||||
expect(payloads).toHaveLength(1);
|
||||
expect(payloads[0]?.text).toBe("Hello world");
|
||||
expect(payloads[0]?.delta).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("does not emit duplicate agent events when message_end repeats", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const onAgentEvent = vi.fn();
|
||||
|
||||
subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||
runId: "run",
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
const assistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_start", message: assistantMessage });
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
handler?.({ type: "message_end", message: assistantMessage });
|
||||
|
||||
const payloads = onAgentEvent.mock.calls
|
||||
.map((call) => call[0]?.data as Record<string, unknown> | undefined)
|
||||
.filter((value): value is Record<string, unknown> => Boolean(value));
|
||||
expect(payloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips agent events when cleaned text rewinds mid-stream", () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -49,6 +49,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
partialBlockState: { thinking: false, final: false, inlineCode: createInlineCodeState() },
|
||||
lastStreamedAssistant: undefined,
|
||||
lastStreamedAssistantCleaned: undefined,
|
||||
emittedAssistantUpdate: false,
|
||||
lastStreamedReasoning: undefined,
|
||||
lastBlockReplyText: undefined,
|
||||
assistantMessageIndex: 0,
|
||||
@@ -95,6 +96,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
state.partialBlockState.inlineCode = createInlineCodeState();
|
||||
state.lastStreamedAssistant = undefined;
|
||||
state.lastStreamedAssistantCleaned = undefined;
|
||||
state.emittedAssistantUpdate = false;
|
||||
state.lastBlockReplyText = undefined;
|
||||
state.lastStreamedReasoning = undefined;
|
||||
state.lastReasoningSent = undefined;
|
||||
|
||||
Reference in New Issue
Block a user