fix: preserve structured attachment outputs (#17)
This commit is contained in:
@@ -356,7 +356,7 @@ async function runQuery(
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
phase: 'intermediate',
|
phase: 'intermediate',
|
||||||
result: pendingProgressText,
|
...normalizeStructuredOutput(pendingProgressText),
|
||||||
newSessionId,
|
newSessionId,
|
||||||
});
|
});
|
||||||
pendingProgressText = null;
|
pendingProgressText = null;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type RunnerOutputVerdict =
|
|||||||
| 'done'
|
| 'done'
|
||||||
| 'done_with_concerns'
|
| 'done_with_concerns'
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
|
| 'in_progress'
|
||||||
| 'silent';
|
| 'silent';
|
||||||
|
|
||||||
export type RunnerOutputVisibility = 'public' | 'silent';
|
export type RunnerOutputVisibility = 'public' | 'silent';
|
||||||
@@ -93,10 +94,20 @@ function isVisibleVerdict(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
): value is Exclude<RunnerOutputVerdict, 'silent'> {
|
): value is Exclude<RunnerOutputVerdict, 'silent'> {
|
||||||
return (
|
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[] {
|
function normalizeAttachments(value: unknown): RunnerOutputAttachment[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
@@ -138,7 +149,7 @@ export function normalizeEjclawStructuredOutput(
|
|||||||
return { result };
|
return { result };
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = result.trim();
|
const trimmed = stripLeadingStructuredOutputControls(result.trim());
|
||||||
const jsonCandidate = extractStructuredJsonCandidate(trimmed);
|
const jsonCandidate = extractStructuredJsonCandidate(trimmed);
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(jsonCandidate) as {
|
const parsed = JSON.parse(jsonCandidate) as {
|
||||||
|
|||||||
@@ -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', () => {
|
it('parses unlabeled fenced ejclaw envelopes', () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeEjclawStructuredOutput(`\`\`\`
|
normalizeEjclawStructuredOutput(`\`\`\`
|
||||||
|
|||||||
@@ -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 () => {
|
it('replaces the tracked progress message when an owner final arrives', async () => {
|
||||||
const channel = {
|
const channel = {
|
||||||
...makeChannel(),
|
...makeChannel(),
|
||||||
|
|||||||
@@ -666,7 +666,10 @@ export class MessageTurnController {
|
|||||||
await this.flushPendingProgress(options.flushPendingText);
|
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, {
|
await this.deliverFinalText(text, {
|
||||||
...(options?.attachments?.length
|
...(options?.attachments?.length
|
||||||
? { attachments: options.attachments }
|
? { attachments: options.attachments }
|
||||||
@@ -690,6 +693,20 @@ export class MessageTurnController {
|
|||||||
return replaceMessageId;
|
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(
|
private async deliverFinalText(
|
||||||
text: string,
|
text: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user