From d52556fd28904386762fe7663747f261f8b47a2c Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sat, 25 Apr 2026 14:41:46 +0900 Subject: [PATCH] fix: preserve structured attachment outputs (#17) --- runners/agent-runner/src/index.ts | 2 +- runners/shared/src/agent-protocol.ts | 15 ++++- runners/shared/test/agent-protocol.test.ts | 71 ++++++++++++++++++++++ src/message-turn-controller.test.ts | 65 ++++++++++++++++++++ src/message-turn-controller.ts | 19 +++++- 5 files changed, 168 insertions(+), 4 deletions(-) diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 9665bba..295daff 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -356,7 +356,7 @@ async function runQuery( writeOutput({ status: 'success', phase: 'intermediate', - result: pendingProgressText, + ...normalizeStructuredOutput(pendingProgressText), newSessionId, }); pendingProgressText = null; diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index de2e6cc..6268d66 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -18,6 +18,7 @@ export type RunnerOutputVerdict = | 'done' | 'done_with_concerns' | 'blocked' + | 'in_progress' | 'silent'; export type RunnerOutputVisibility = 'public' | 'silent'; @@ -93,10 +94,20 @@ function isVisibleVerdict( value: unknown, ): value is Exclude { return ( - value === 'done' || value === 'done_with_concerns' || value === 'blocked' + value === 'done' || + value === 'done_with_concerns' || + value === 'blocked' || + value === 'in_progress' ); } +const LEADING_STRUCTURED_OUTPUT_CONTROL_RE = + /^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u; + +function stripLeadingStructuredOutputControls(value: string): string { + return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart(); +} + function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { if (!Array.isArray(value)) return []; @@ -138,7 +149,7 @@ export function normalizeEjclawStructuredOutput( return { result }; } - const trimmed = result.trim(); + const trimmed = stripLeadingStructuredOutputControls(result.trim()); const jsonCandidate = extractStructuredJsonCandidate(trimmed); try { const parsed = JSON.parse(jsonCandidate) as { diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 7b166f6..da4a26d 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -121,6 +121,77 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses fenced ejclaw envelopes with leading invisible control characters', () => { + expect( + normalizeEjclawStructuredOutput(`\u2063\u2063\u2063\`\`\`json +{ + "ejclaw": { + "visibility": "public", + "text": "최종 이미지를 첨부했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/tmp/ejclaw-discord-image-final.png", + "name": "final.png", + "mime": "image/png" + } + ] + } +} +\`\`\``), + ).toEqual({ + result: '최종 이미지를 첨부했습니다.', + output: { + visibility: 'public', + text: '최종 이미지를 첨부했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-final.png', + name: 'final.png', + mime: 'image/png', + }, + ], + }, + }); + }); + + it('parses in_progress public envelopes instead of leaking raw structured JSON', () => { + const raw = JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '이미지를 생성하는 중입니다.', + verdict: 'in_progress', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-draft.png', + name: 'draft.png', + mime: 'image/png', + }, + ], + }, + }); + + const normalized = normalizeEjclawStructuredOutput(raw); + + expect(normalized).toEqual({ + result: '이미지를 생성하는 중입니다.', + output: { + visibility: 'public', + text: '이미지를 생성하는 중입니다.', + verdict: 'in_progress', + attachments: [ + { + path: '/tmp/ejclaw-discord-image-draft.png', + name: 'draft.png', + mime: 'image/png', + }, + ], + }, + }); + expect(normalized.result).not.toContain('"ejclaw"'); + }); + it('parses unlabeled fenced ejclaw envelopes', () => { expect( normalizeEjclawStructuredOutput(`\`\`\` diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index acc4912..a48f153 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -370,6 +370,71 @@ describe('MessageTurnController outbound audit logging', () => { ); }); + it('sends final attachments as a fresh final message instead of replacing text-only progress', async () => { + const channel = { + ...makeChannel(), + name: 'discord', + } satisfies Channel; + const deliverFinalText = vi.fn().mockResolvedValue(true); + const attachments = [ + { + path: '/tmp/ejclaw-discord-image-final.png', + name: 'final.png', + mime: 'image/png', + }, + ]; + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-owner-final-attachment-send', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn', + taskId: 'task-1', + taskUpdatedAt: '2026-04-10T14:22:00.000Z', + intentKind: 'finalize-owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '이미지 렌더링 중', + } as any); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '이미지 파일 쓰는 중', + } as any); + await flushAsync(); + await controller.handleOutput({ + status: 'success', + phase: 'final', + result: '이미지 렌더 완료.', + output: { + visibility: 'public', + text: '이미지 렌더 완료.', + attachments, + }, + } as any); + await controller.finish('success'); + + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(deliverFinalText).toHaveBeenCalledWith('이미지 렌더 완료.', { + replaceMessageId: null, + attachments, + }); + }); + it('replaces the tracked progress message when an owner final arrives', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index ba0b6fd..6e48f8d 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -666,7 +666,10 @@ export class MessageTurnController { await this.flushPendingProgress(options.flushPendingText); } - const replaceMessageId = this.consumeProgressForFinalDelivery(); + const hasAttachments = (options?.attachments?.length ?? 0) > 0; + const replaceMessageId = hasAttachments + ? this.discardProgressForAttachmentFinalDelivery() + : this.consumeProgressForFinalDelivery(); await this.deliverFinalText(text, { ...(options?.attachments?.length ? { attachments: options.attachments } @@ -690,6 +693,20 @@ export class MessageTurnController { return replaceMessageId; } + private discardProgressForAttachmentFinalDelivery(): null { + this.log.info( + { + progressMessageId: this.progressMessageId, + latestProgressText: this.latestProgressText, + }, + this.progressMessageId + ? 'Discarding tracked progress replacement for final attachment delivery' + : 'Delivering final attachment output without a tracked progress message to replace', + ); + this.resetProgressState(); + return null; + } + private async deliverFinalText( text: string, options?: {