mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 05:01:23 +00:00
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: e4a5e3c8a6
Co-authored-by: TsekaLuk <79151285+TsekaLuk@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
|
- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
|
||||||
|
- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
|
||||||
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
|
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
|
||||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||||
|
|||||||
@@ -89,4 +89,109 @@ describe("TuiStreamAssembler", () => {
|
|||||||
|
|
||||||
expect(second).toBeNull();
|
expect(second).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps richer streamed text when final payload drops earlier blocks", () => {
|
||||||
|
const assembler = new TuiStreamAssembler();
|
||||||
|
assembler.ingestDelta(
|
||||||
|
"run-5",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Before tool call" },
|
||||||
|
{ type: "tool_use", name: "search" },
|
||||||
|
{ type: "text", text: "After tool call" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalText = assembler.finalize(
|
||||||
|
"run-5",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", name: "search" },
|
||||||
|
{ type: "text", text: "After tool call" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalText).toBe("Before tool call\nAfter tool call");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-empty final text for plain text prefix/suffix updates", () => {
|
||||||
|
const assembler = new TuiStreamAssembler();
|
||||||
|
assembler.ingestDelta(
|
||||||
|
"run-5b",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Draft line 1" },
|
||||||
|
{ type: "text", text: "Draft line 2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalText = assembler.finalize(
|
||||||
|
"run-5b",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Draft line 1" }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalText).toBe("Draft line 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts richer final payload when it extends streamed text", () => {
|
||||||
|
const assembler = new TuiStreamAssembler();
|
||||||
|
assembler.ingestDelta(
|
||||||
|
"run-6",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Before tool call" }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalText = assembler.finalize(
|
||||||
|
"run-6",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Before tool call" },
|
||||||
|
{ type: "text", text: "After tool call" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalText).toBe("Before tool call\nAfter tool call");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers non-empty final payload when it is not a dropped block regression", () => {
|
||||||
|
const assembler = new TuiStreamAssembler();
|
||||||
|
assembler.ingestDelta(
|
||||||
|
"run-7",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "NOT OK" }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalText = assembler.finalize(
|
||||||
|
"run-7",
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "OK" }],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalText).toBe("OK");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,73 @@ import {
|
|||||||
type RunStreamState = {
|
type RunStreamState = {
|
||||||
thinkingText: string;
|
thinkingText: string;
|
||||||
contentText: string;
|
contentText: string;
|
||||||
|
contentBlocks: string[];
|
||||||
|
sawNonTextContentBlocks: boolean;
|
||||||
displayText: string;
|
displayText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function extractTextBlocksAndSignals(message: unknown): {
|
||||||
|
textBlocks: string[];
|
||||||
|
sawNonTextContentBlocks: boolean;
|
||||||
|
} {
|
||||||
|
if (!message || typeof message !== "object") {
|
||||||
|
return { textBlocks: [], sawNonTextContentBlocks: false };
|
||||||
|
}
|
||||||
|
const record = message as Record<string, unknown>;
|
||||||
|
const content = record.content;
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
const text = content.trim();
|
||||||
|
return {
|
||||||
|
textBlocks: text ? [text] : [],
|
||||||
|
sawNonTextContentBlocks: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return { textBlocks: [], sawNonTextContentBlocks: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const textBlocks: string[] = [];
|
||||||
|
let sawNonTextContentBlocks = false;
|
||||||
|
for (const block of content) {
|
||||||
|
if (!block || typeof block !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rec = block as Record<string, unknown>;
|
||||||
|
if (rec.type === "text" && typeof rec.text === "string") {
|
||||||
|
const text = rec.text.trim();
|
||||||
|
if (text) {
|
||||||
|
textBlocks.push(text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof rec.type === "string" && rec.type !== "thinking") {
|
||||||
|
sawNonTextContentBlocks = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { textBlocks, sawNonTextContentBlocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDroppedBoundaryTextBlockSubset(params: {
|
||||||
|
streamedTextBlocks: string[];
|
||||||
|
finalTextBlocks: string[];
|
||||||
|
}): boolean {
|
||||||
|
const { streamedTextBlocks, finalTextBlocks } = params;
|
||||||
|
if (finalTextBlocks.length === 0 || finalTextBlocks.length >= streamedTextBlocks.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixMatches = finalTextBlocks.every(
|
||||||
|
(block, index) => streamedTextBlocks[index] === block,
|
||||||
|
);
|
||||||
|
if (prefixMatches) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixStart = streamedTextBlocks.length - finalTextBlocks.length;
|
||||||
|
return finalTextBlocks.every((block, index) => streamedTextBlocks[suffixStart + index] === block);
|
||||||
|
}
|
||||||
|
|
||||||
export class TuiStreamAssembler {
|
export class TuiStreamAssembler {
|
||||||
private runs = new Map<string, RunStreamState>();
|
private runs = new Map<string, RunStreamState>();
|
||||||
|
|
||||||
@@ -20,6 +84,8 @@ export class TuiStreamAssembler {
|
|||||||
state = {
|
state = {
|
||||||
thinkingText: "",
|
thinkingText: "",
|
||||||
contentText: "",
|
contentText: "",
|
||||||
|
contentBlocks: [],
|
||||||
|
sawNonTextContentBlocks: false,
|
||||||
displayText: "",
|
displayText: "",
|
||||||
};
|
};
|
||||||
this.runs.set(runId, state);
|
this.runs.set(runId, state);
|
||||||
@@ -30,12 +96,17 @@ export class TuiStreamAssembler {
|
|||||||
private updateRunState(state: RunStreamState, message: unknown, showThinking: boolean) {
|
private updateRunState(state: RunStreamState, message: unknown, showThinking: boolean) {
|
||||||
const thinkingText = extractThinkingFromMessage(message);
|
const thinkingText = extractThinkingFromMessage(message);
|
||||||
const contentText = extractContentFromMessage(message);
|
const contentText = extractContentFromMessage(message);
|
||||||
|
const { textBlocks, sawNonTextContentBlocks } = extractTextBlocksAndSignals(message);
|
||||||
|
|
||||||
if (thinkingText) {
|
if (thinkingText) {
|
||||||
state.thinkingText = thinkingText;
|
state.thinkingText = thinkingText;
|
||||||
}
|
}
|
||||||
if (contentText) {
|
if (contentText) {
|
||||||
state.contentText = contentText;
|
state.contentText = contentText;
|
||||||
|
state.contentBlocks = textBlocks.length > 0 ? textBlocks : [contentText];
|
||||||
|
}
|
||||||
|
if (sawNonTextContentBlocks) {
|
||||||
|
state.sawNonTextContentBlocks = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayText = composeThinkingAndContent({
|
const displayText = composeThinkingAndContent({
|
||||||
@@ -61,11 +132,20 @@ export class TuiStreamAssembler {
|
|||||||
|
|
||||||
finalize(runId: string, message: unknown, showThinking: boolean): string {
|
finalize(runId: string, message: unknown, showThinking: boolean): string {
|
||||||
const state = this.getOrCreateRun(runId);
|
const state = this.getOrCreateRun(runId);
|
||||||
|
const streamedDisplayText = state.displayText;
|
||||||
|
const streamedTextBlocks = [...state.contentBlocks];
|
||||||
|
const streamedSawNonTextContentBlocks = state.sawNonTextContentBlocks;
|
||||||
this.updateRunState(state, message, showThinking);
|
this.updateRunState(state, message, showThinking);
|
||||||
const finalComposed = state.displayText;
|
const finalComposed = state.displayText;
|
||||||
|
const shouldKeepStreamedText =
|
||||||
|
streamedSawNonTextContentBlocks &&
|
||||||
|
isDroppedBoundaryTextBlockSubset({
|
||||||
|
streamedTextBlocks,
|
||||||
|
finalTextBlocks: state.contentBlocks,
|
||||||
|
});
|
||||||
const finalText = resolveFinalAssistantText({
|
const finalText = resolveFinalAssistantText({
|
||||||
finalText: finalComposed,
|
finalText: shouldKeepStreamedText ? streamedDisplayText : finalComposed,
|
||||||
streamedText: state.displayText,
|
streamedText: streamedDisplayText,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
|
|||||||
Reference in New Issue
Block a user