Support status-prefixed structured attachments
Support status-prefixed structured attachment output and keep visible verdict text.
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user