Support status-prefixed structured attachments

Support status-prefixed structured attachment output and keep visible verdict text.
This commit is contained in:
Eyejoker
2026-05-02 19:22:55 +09:00
committed by GitHub
parent fb32726ac0
commit 9e45534de0
4 changed files with 151 additions and 3 deletions

View File

@@ -75,6 +75,34 @@ describe('agent runner IPC message payload', () => {
}); });
}); });
it('normalizes status-prefixed EJClaw envelopes and preserves attachments', () => {
expect(
buildSendMessageIpcPayload({
chatJid: 'dc:123',
text: `TASK_DONE
\`\`\`json
{"ejclaw":{"visibility":"public","text":"첨부했습니다.","verdict":"done","attachments":[{"path":"/tmp/ejclaw-status.png","name":"status.png","mime":"image/png"}]}}
\`\`\``,
groupFolder: 'discord-review',
timestamp: '2026-04-04T13:45:00.000Z',
}),
).toEqual({
type: 'message',
chatJid: 'dc:123',
text: 'TASK_DONE\n\n첨부했습니다.',
groupFolder: 'discord-review',
timestamp: '2026-04-04T13:45:00.000Z',
attachments: [
{
path: '/tmp/ejclaw-status.png',
name: 'status.png',
mime: 'image/png',
},
],
});
});
it('turns silent EJClaw envelopes into empty no-op messages', () => { it('turns silent EJClaw envelopes into empty no-op messages', () => {
expect( expect(
buildSendMessageIpcPayload({ buildSendMessageIpcPayload({

View File

@@ -103,6 +103,8 @@ function isVisibleVerdict(
const LEADING_STRUCTURED_OUTPUT_CONTROL_RE = const LEADING_STRUCTURED_OUTPUT_CONTROL_RE =
/^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u; /^[\u0000-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]+/u;
const STRUCTURED_STATUS_PREFIX_RE =
/^(STEP_DONE|TASK_DONE|DONE|DONE_WITH_CONCERNS|BLOCKED|NEEDS_CONTEXT)[ \t]*(?:\r?\n)+([\s\S]+)$/;
function stripLeadingStructuredOutputControls(value: string): string { function stripLeadingStructuredOutputControls(value: string): string {
return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart(); return value.replace(LEADING_STRUCTURED_OUTPUT_CONTROL_RE, '').trimStart();
@@ -142,6 +144,31 @@ function extractStructuredJsonCandidate(trimmed: string): string {
return fencedJson?.[1]?.trim() ?? trimmed; return fencedJson?.[1]?.trim() ?? trimmed;
} }
function extractStructuredCandidateWithOptionalStatus(trimmed: string): {
jsonCandidate: string;
statusPrefix: string | null;
} {
const statusMatch = trimmed.match(STRUCTURED_STATUS_PREFIX_RE);
if (!statusMatch) {
return {
jsonCandidate: extractStructuredJsonCandidate(trimmed),
statusPrefix: null,
};
}
return {
jsonCandidate: extractStructuredJsonCandidate(statusMatch[2].trim()),
statusPrefix: statusMatch[1],
};
}
function prefixStructuredText(
text: string,
statusPrefix: string | null,
): string {
return statusPrefix ? `${statusPrefix}\n\n${text}` : text;
}
export function normalizeEjclawStructuredOutput( export function normalizeEjclawStructuredOutput(
result: string | null, result: string | null,
): NormalizedRunnerOutput { ): NormalizedRunnerOutput {
@@ -150,7 +177,8 @@ export function normalizeEjclawStructuredOutput(
} }
const trimmed = stripLeadingStructuredOutputControls(result.trim()); const trimmed = stripLeadingStructuredOutputControls(result.trim());
const jsonCandidate = extractStructuredJsonCandidate(trimmed); const { jsonCandidate, statusPrefix } =
extractStructuredCandidateWithOptionalStatus(trimmed);
try { try {
const parsed = JSON.parse(jsonCandidate) as { const parsed = JSON.parse(jsonCandidate) as {
ejclaw?: { ejclaw?: {
@@ -188,11 +216,12 @@ export function normalizeEjclawStructuredOutput(
return normalizePublicTextOutput(result); return normalizePublicTextOutput(result);
} }
const attachments = normalizeAttachments(envelope.attachments); const attachments = normalizeAttachments(envelope.attachments);
const text = prefixStructuredText(envelope.text, statusPrefix);
return { return {
result: envelope.text, result: text,
output: { output: {
visibility: 'public', visibility: 'public',
text: envelope.text, text,
verdict: isVisibleVerdict(envelope.verdict) verdict: isVisibleVerdict(envelope.verdict)
? envelope.verdict ? envelope.verdict
: undefined, : undefined,

View File

@@ -156,6 +156,45 @@ describe('shared agent protocol helpers', () => {
}); });
}); });
it('parses status-prefixed fenced public ejclaw attachments', () => {
expect(
normalizeEjclawStructuredOutput(`TASK_DONE
\`\`\`json
{
"ejclaw": {
"visibility": "public",
"text": "이미지를 첨부했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/tmp/ejclaw-discord-image-status.png",
"name": "status.png",
"mime": "image/png"
}
]
}
}
\`\`\``),
).toEqual({
result: 'TASK_DONE\n\n이미지를 첨부했습니다.',
output: {
visibility: 'public',
text: 'TASK_DONE\n\n이미지를 첨부했습니다.',
verdict: 'done',
attachments: [
{
path: '/tmp/ejclaw-discord-image-status.png',
name: 'status.png',
mime: 'image/png',
},
],
},
});
});
});
describe('shared agent protocol fallback behavior', () => {
it('parses in_progress public envelopes instead of leaking raw structured JSON', () => { it('parses in_progress public envelopes instead of leaking raw structured JSON', () => {
const raw = JSON.stringify({ const raw = JSON.stringify({
ejclaw: { ejclaw: {

View File

@@ -173,4 +173,56 @@ describe('DiscordChannel structured output', () => {
'"ejclaw"', '"ejclaw"',
); );
}); });
it('normalizes status-prefixed EJClaw JSON and keeps the status line visible', async () => {
const channel = new DiscordChannel('test-token', createTestOpts());
await channel.connect();
const filePath = path.join(
os.tmpdir(),
`ejclaw-discord-image-status-${Date.now()}.png`,
);
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
tempFiles.push(filePath);
const mockChannel = {
send: vi.fn().mockResolvedValue({ id: 'discord-message-2' }),
sendTyping: vi.fn(),
};
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage(
'dc:1234567890123456',
`TASK_DONE
\`\`\`json
${JSON.stringify({
ejclaw: {
visibility: 'public',
text: '첨부 테스트입니다.',
verdict: 'done',
attachments: [
{
path: filePath,
name: 'status.png',
mime: 'image/png',
},
],
},
})}
\`\`\``,
);
expect(mockChannel.send).toHaveBeenCalledWith({
content: 'TASK_DONE\n\n첨부 테스트입니다.',
files: [
{
attachment: fs.realpathSync(filePath),
name: 'status.png',
},
],
flags: 1 << 2,
});
expect(JSON.stringify(mockChannel.send.mock.calls)).not.toContain(
'"ejclaw"',
);
});
}); });