Skip to content

Commit 62692de

Browse files
Merge branch 'main' into fix/edit-fuzzy-v2
2 parents b5ddf0c + 2e6e17f commit 62692de

32 files changed

Lines changed: 773 additions & 106 deletions

extensions/discord/src/outbound-adapter.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,22 +345,47 @@ describe("discordOutbound", () => {
345345
2,
346346
);
347347
expect(messageOptions.accountId).toBe("default");
348-
expect(messageOptions.replyTo).toBeUndefined();
348+
expect(messageOptions.replyTo).toBe("reply-1");
349349

350350
const mediaCall = mockCall(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1);
351351
expect(mediaCall[0]).toBe("channel:123456");
352352
expect(mediaCall[1]).toBe("");
353353
const mediaOptions = mockObjectArg(hoisted.sendMessageDiscordMock, "sendMessageDiscord", 1, 2);
354354
expect(mediaOptions.accountId).toBe("default");
355355
expect(mediaOptions.mediaUrl).toBe("https://example.com/extra.png");
356-
expect(mediaOptions.replyTo).toBeUndefined();
356+
expect(mediaOptions.replyTo).toBe("reply-1");
357357
expect(result).toEqual({
358358
channel: "discord",
359359
messageId: "msg-1",
360360
channelId: "ch-1",
361361
});
362362
});
363363

364+
it("keeps captured replyTo on audioAsVoice sends when replyToMode is batched", async () => {
365+
await discordOutbound.sendPayload?.({
366+
cfg: {},
367+
to: "channel:123456",
368+
text: "",
369+
payload: {
370+
text: "voice note",
371+
mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.png"],
372+
audioAsVoice: true,
373+
},
374+
accountId: "default",
375+
replyToId: "reply-1",
376+
replyToMode: "batched",
377+
});
378+
379+
expect(
380+
mockObjectArg(hoisted.sendVoiceMessageDiscordMock, "sendVoiceMessageDiscord", 0, 2).replyTo,
381+
).toBe("reply-1");
382+
expect(
383+
hoisted.sendMessageDiscordMock.mock.calls.map(
384+
(call) => (call[2] as { replyTo?: unknown } | undefined)?.replyTo,
385+
),
386+
).toEqual(["reply-1", "reply-1"]);
387+
});
388+
364389
it("keeps replyToId on every internal audioAsVoice send when replyToMode is all", async () => {
365390
await discordOutbound.sendPayload?.({
366391
cfg: {},

extensions/discord/src/outbound-payload.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,23 @@ export async function sendDiscordOutboundPayload(params: {
8484
const sendContext = await createDiscordPayloadSendContext(ctx);
8585

8686
if (payload.audioAsVoice && mediaUrls.length > 0) {
87+
// audioAsVoice emits one logical Discord reply across voice/text/media sends.
88+
// Capture before helper calls consume implicit single-use reply targets.
89+
const voiceReplyTo = sendContext.resolveReplyTo();
8790
let lastResult = await sendContext.withRetry(
8891
async () =>
89-
await sendContext.sendVoice(
90-
sendContext.target,
91-
mediaUrls[0],
92-
resolveDiscordDeliveryOptions(ctx, sendContext),
93-
),
92+
await sendContext.sendVoice(sendContext.target, mediaUrls[0], {
93+
...resolveDiscordDeliveryOptions(ctx, sendContext),
94+
replyTo: voiceReplyTo,
95+
}),
9496
);
9597
if (payload.text?.trim()) {
9698
lastResult = await sendContext.withRetry(
9799
async () =>
98100
await sendContext.send(sendContext.target, payload.text, {
99101
verbose: false,
100102
...resolveDiscordFormattedDeliveryOptions(ctx, sendContext),
103+
replyTo: voiceReplyTo,
101104
}),
102105
);
103106
}
@@ -107,6 +110,7 @@ export async function sendDiscordOutboundPayload(params: {
107110
await sendContext.send(sendContext.target, "", {
108111
verbose: false,
109112
...resolveDiscordMediaDeliveryOptions(ctx, sendContext, mediaUrl),
113+
replyTo: voiceReplyTo,
110114
}),
111115
);
112116
}

extensions/document-extract/document-extractor.test.ts

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -55,20 +55,35 @@ describe("PDF document extractor", () => {
5555
});
5656
});
5757

58-
it("extracts text first and renders fallback images through clawpdf", async () => {
59-
pdfDocument.extract.mockResolvedValueOnce({ text: "", images: [] }).mockResolvedValueOnce({
60-
text: "",
61-
images: [
62-
{
63-
type: "image",
64-
bytes: Uint8Array.from(Buffer.from("png")),
65-
mimeType: "image/png",
66-
page: 1,
67-
width: 10,
68-
height: 10,
69-
},
70-
],
71-
});
58+
it("extracts text first and renders each fallback page with its own pixel budget", async () => {
59+
pdfDocument.extract
60+
.mockResolvedValueOnce({ text: "", images: [] })
61+
.mockResolvedValueOnce({
62+
text: "",
63+
images: [
64+
{
65+
type: "image",
66+
bytes: Uint8Array.from(Buffer.from("png1")),
67+
mimeType: "image/png",
68+
page: 1,
69+
width: 5,
70+
height: 10,
71+
},
72+
],
73+
})
74+
.mockResolvedValueOnce({
75+
text: "",
76+
images: [
77+
{
78+
type: "image",
79+
bytes: Uint8Array.from(Buffer.from("png2")),
80+
mimeType: "image/png",
81+
page: 2,
82+
width: 5,
83+
height: 10,
84+
},
85+
],
86+
});
7287
const extractor = createPdfDocumentExtractor();
7388

7489
const result = await extractor.extract(request());
@@ -82,18 +97,24 @@ describe("PDF document extractor", () => {
8297
maxPages: 2,
8398
maxTextChars: 200_000,
8499
});
100+
// Each page renders in its own extract() call, with the aggregate pixel cap
101+
// allocated across selected pages so later pages are not starved.
85102
expect(pdfDocument.extract).toHaveBeenNthCalledWith(2, {
86103
mode: "images",
87-
maxPages: 2,
88-
image: {
89-
maxDimension: 10_000,
90-
maxPixels: 100,
91-
forms: true,
92-
},
104+
pages: [1],
105+
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
106+
});
107+
expect(pdfDocument.extract).toHaveBeenNthCalledWith(3, {
108+
mode: "images",
109+
pages: [2],
110+
image: { maxDimension: 10_000, maxPixels: 50, forms: true },
93111
});
94112
expect(result).toEqual({
95113
text: "",
96-
images: [{ type: "image", data: "cG5n", mimeType: "image/png" }],
114+
images: [
115+
{ type: "image", data: "cG5nMQ==", mimeType: "image/png" },
116+
{ type: "image", data: "cG5nMg==", mimeType: "image/png" },
117+
],
97118
});
98119
expect(pdfDocument.destroy).toHaveBeenCalledTimes(1);
99120
});
@@ -131,8 +152,9 @@ describe("PDF document extractor", () => {
131152
expect(pdfDocument.destroy).not.toHaveBeenCalled();
132153
});
133154

134-
it("filters selected pages before passing them to clawpdf", async () => {
155+
it("filters selected pages and renders them one page per image call", async () => {
135156
pdfDocument.extract
157+
.mockResolvedValueOnce({ text: "", images: [] })
136158
.mockResolvedValueOnce({ text: "", images: [] })
137159
.mockResolvedValueOnce({ text: "", images: [] });
138160
const extractor = createPdfDocumentExtractor();
@@ -141,11 +163,15 @@ describe("PDF document extractor", () => {
141163

142164
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
143165
1,
144-
expect.objectContaining({ pages: [2, 1] }),
166+
expect.objectContaining({ mode: "text", pages: [2, 1] }),
145167
);
146168
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
147169
2,
148-
expect.objectContaining({ pages: [2, 1] }),
170+
expect.objectContaining({ mode: "images", pages: [2] }),
171+
);
172+
expect(pdfDocument.extract).toHaveBeenNthCalledWith(
173+
3,
174+
expect.objectContaining({ mode: "images", pages: [1] }),
149175
);
150176
});
151177

extensions/document-extract/document-extractor.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,38 @@ async function extractPdfContent(
8383
return { text, images: [] };
8484
}
8585

86+
// clawpdf's image render budget (maxPixels) is shared across every page in one
87+
// extract() call: the first page consumes it and later pages collapse to 1x1
88+
// PNGs that vision models reject. Render each page separately, allocating the
89+
// remaining aggregate budget across pages that still need rendering.
90+
const imagePages =
91+
pages ?? Array.from({ length: Math.min(pdf.pageCount, request.maxPages) }, (_, i) => i + 1);
92+
8693
try {
87-
const imageResult = await pdf.extract({
88-
mode: "images",
89-
...pageSelection,
90-
image: {
91-
maxDimension: MAX_RENDER_DIMENSION,
92-
maxPixels: request.maxPixels,
93-
forms: true,
94-
},
95-
});
96-
return { text, images: imageResult.images.map(toDocumentImage) };
94+
const images: DocumentExtractedImage[] = [];
95+
let remainingPixels = request.maxPixels;
96+
for (let index = 0; index < imagePages.length; index += 1) {
97+
if (remainingPixels <= 0) {
98+
break;
99+
}
100+
const pagesRemaining = imagePages.length - index;
101+
const maxPixelsPerPage = Math.max(1, Math.ceil(remainingPixels / pagesRemaining));
102+
const pageNumber = imagePages[index];
103+
const imageResult = await pdf.extract({
104+
mode: "images",
105+
pages: [pageNumber],
106+
image: {
107+
maxDimension: MAX_RENDER_DIMENSION,
108+
maxPixels: maxPixelsPerPage,
109+
forms: true,
110+
},
111+
});
112+
for (const image of imageResult.images) {
113+
images.push(toDocumentImage(image));
114+
remainingPixels -= image.width * image.height;
115+
}
116+
}
117+
return { text, images };
97118
} catch (err) {
98119
request.onImageExtractionError?.(err);
99120
return { text, images: [] };

extensions/imessage/src/monitor/sanitize-outbound.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ describe("sanitizeOutboundText", () => {
4949
expect(result).not.toMatch(/^assistant:$/m);
5050
});
5151

52+
it("preserves prose lines that merely end with 'user:'/'system:'", () => {
53+
expect(sanitizeOutboundText("Please send this reply to the user:")).toBe(
54+
"Please send this reply to the user:",
55+
);
56+
expect(sanitizeOutboundText("Here is a note for the system:")).toBe(
57+
"Here is a note for the system:",
58+
);
59+
});
60+
5261
it("collapses excessive blank lines after stripping", () => {
5362
const text = "Hello\n\n\n\n\nWorld";
5463
expect(sanitizeOutboundText(text)).toBe("Hello\n\nWorld");

extensions/imessage/src/monitor/sanitize-outbound.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import { stripAssistantInternalScaffolding } from "openclaw/plugin-sdk/text-chun
77
*/
88
const INTERNAL_SEPARATOR_RE = /(?:#\+){2,}#?/g;
99
const ASSISTANT_ROLE_MARKER_RE = /\bassistant\s+to\s*=\s*\w+/gi;
10-
const ROLE_TURN_MARKER_RE = /\b(?:user|system|assistant)\s*:\s*$/gm;
10+
// Only a standalone role marker on its own line (a leaked turn boundary) — not
11+
// any line that merely ends with the word "user/system/assistant:" in prose.
12+
const ROLE_TURN_MARKER_RE = /^[ \t]*(?:user|system|assistant)\s*:\s*$/gm;
1113

1214
/**
1315
* Strip all assistant-internal scaffolding from outbound text before delivery.

git-hooks/pre-commit

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ if [[ ! -f "$FILTER_FILES" ]]; then
1616
exit 1
1717
fi
1818

19+
GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)"
20+
if [[ -n "$GIT_DIR" ]] && \
21+
{ [[ -f "$GIT_DIR/MERGE_HEAD" ]] || \
22+
[[ -f "$GIT_DIR/CHERRY_PICK_HEAD" ]] || \
23+
[[ -f "$GIT_DIR/REVERT_HEAD" ]] || \
24+
[[ -f "$GIT_DIR/REBASE_HEAD" ]] || \
25+
[[ -d "$GIT_DIR/rebase-merge" ]] || \
26+
[[ -d "$GIT_DIR/rebase-apply" ]]; }; then
27+
# Sequencer commits stage the operation result, not just the user's local edits.
28+
exit 0
29+
fi
30+
1931
# Security: avoid option-injection from malicious file names (e.g. "--all", "--force").
2032
# Robustness: NUL-delimited file list handles spaces/newlines safely.
2133
# Compatibility: use read loops instead of `mapfile` so this runs on macOS Bash 3.x.

packages/acp-core/src/session.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ describe("acp session manager", () => {
3434
expect(store.getSessionByRunId("run-1")).toBeUndefined();
3535
});
3636

37+
it("removes stale run lookup entries when rebinding an active run", () => {
38+
const session = store.createSession({
39+
sessionKey: "acp:rebind",
40+
cwd: "/tmp",
41+
});
42+
43+
store.setActiveRun(session.sessionId, "run-old", new AbortController());
44+
store.setActiveRun(session.sessionId, "run-new", new AbortController());
45+
46+
expect(store.getSessionByRunId("run-old")).toBeUndefined();
47+
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
48+
});
49+
3750
it("deletes sessions and aborts active runs on close", () => {
3851
const session = store.createSession({
3952
sessionId: "close-me",

packages/acp-core/src/session.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ export function createInMemorySessionStore(options: AcpSessionStoreOptions = {})
150150
if (!session) {
151151
return;
152152
}
153+
if (session.activeRunId && session.activeRunId !== runId) {
154+
runIdToSessionId.delete(session.activeRunId);
155+
}
153156
session.activeRunId = runId;
154157
session.abortController = abortController;
155158
runIdToSessionId.set(runId, sessionId);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Agent Core tests cover prompt template argument parsing behavior.
2+
import { describe, expect, it } from "vitest";
3+
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
4+
5+
describe("prompt template arguments", () => {
6+
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
7+
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
8+
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
9+
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
10+
});
11+
});

0 commit comments

Comments
 (0)