fix: preserve structured attachment outputs (#17)

This commit is contained in:
Eyejoker
2026-04-25 14:41:46 +09:00
committed by GitHub
parent a74ea4bad0
commit d52556fd28
5 changed files with 168 additions and 4 deletions

View File

@@ -356,7 +356,7 @@ async function runQuery(
writeOutput({
status: 'success',
phase: 'intermediate',
result: pendingProgressText,
...normalizeStructuredOutput(pendingProgressText),
newSessionId,
});
pendingProgressText = null;

View File

@@ -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<RunnerOutputVerdict, 'silent'> {
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 {

View File

@@ -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(`\`\`\`

View File

@@ -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(),

View File

@@ -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?: {