Merge remote-tracking branch 'origin/main' into codex/owner/ejclaw
This commit is contained in:
@@ -7,22 +7,10 @@ When working as a sub-agent or teammate, only use `send_message` if the main age
|
|||||||
|
|
||||||
## Image attachments
|
## Image attachments
|
||||||
|
|
||||||
When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose:
|
When a locally generated image or screenshot should appear in Discord, include a Markdown image with an absolute local path:
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|

|
||||||
"ejclaw": {
|
|
||||||
"visibility": "public",
|
|
||||||
"text": "스크린샷을 첨부했습니다.",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"path": "/absolute/path/screenshot.png",
|
|
||||||
"name": "screenshot.png",
|
|
||||||
"mime": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter. Use absolute local paths only, do not repeat the same path in the visible text, and only attach raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
|
|||||||
@@ -30,27 +30,15 @@ Your output is sent directly to the Discord group.
|
|||||||
|
|
||||||
## Image attachments
|
## Image attachments
|
||||||
|
|
||||||
When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`.
|
When you need to show a locally generated image, screenshot, or other raster output in Discord, include a Markdown image with an absolute local path:
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|

|
||||||
"ejclaw": {
|
|
||||||
"visibility": "public",
|
|
||||||
"text": "이미지를 생성했습니다.",
|
|
||||||
"verdict": "done",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"path": "/absolute/path/image.png",
|
|
||||||
"name": "image.png",
|
|
||||||
"mime": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `attachments.path` must be an absolute local path; URLs and relative paths are ignored
|
- You may also use `[Image: /absolute/path/image.png]` when that is shorter
|
||||||
- Do not repeat the same file path in the visible text
|
- URLs and relative paths are ignored
|
||||||
|
- Do not repeat the same file path elsewhere in the visible text
|
||||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
|
- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file
|
||||||
|
|
||||||
|
|||||||
@@ -22,30 +22,16 @@ The group folder may contain a `conversations/` directory with searchable histor
|
|||||||
|
|
||||||
## Image attachments
|
## Image attachments
|
||||||
|
|
||||||
For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths:
|
For locally generated images or e2e screenshots that should appear in Discord, include a Markdown image with an absolute local path:
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|

|
||||||
"ejclaw": {
|
|
||||||
"visibility": "public",
|
|
||||||
"text": "스크린샷을 첨부했습니다.",
|
|
||||||
"verdict": "done",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"path": "/absolute/path/screenshot.png",
|
|
||||||
"name": "screenshot.png",
|
|
||||||
"mime": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- When emitting this as your final runner output, emit the JSON envelope directly. Do not wrap it in Markdown fences or add prose outside the JSON.
|
- You may also use `[Image: /absolute/path/screenshot.png]` when that is shorter
|
||||||
- Use absolute local paths only
|
- Do not duplicate the same path elsewhere in the visible text
|
||||||
- Do not duplicate the same path in the visible text
|
|
||||||
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
|
||||||
- The channel harness validates and uploads attachments; plain prose paths are not reliable
|
- The channel harness validates and uploads attachments from these image markers
|
||||||
|
|
||||||
## CI monitoring (watch_ci)
|
## CI monitoring (watch_ci)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeAgentOutput,
|
||||||
type RunnerOutputAttachment,
|
type RunnerOutputAttachment,
|
||||||
} from 'ejclaw-runners-shared';
|
} from 'ejclaw-runners-shared';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export interface SendMessageIpcPayload {
|
|||||||
export function buildSendMessageIpcPayload(
|
export function buildSendMessageIpcPayload(
|
||||||
input: SendMessageIpcPayloadInput,
|
input: SendMessageIpcPayloadInput,
|
||||||
): SendMessageIpcPayload {
|
): SendMessageIpcPayload {
|
||||||
const normalized = normalizeEjclawStructuredOutput(input.text);
|
const normalized = normalizeAgentOutput(input.text);
|
||||||
const output =
|
const output =
|
||||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||||
const text = output?.text ?? normalized.result ?? '';
|
const text = output?.text ?? normalized.result ?? '';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
extractImageTagPaths,
|
extractImageTagPaths,
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeAgentOutput,
|
||||||
type RunnerStructuredOutput,
|
type RunnerStructuredOutput,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
} from 'ejclaw-runners-shared';
|
} from 'ejclaw-runners-shared';
|
||||||
@@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): {
|
|||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerOutput['output'];
|
output?: RunnerOutput['output'];
|
||||||
} {
|
} {
|
||||||
return normalizeEjclawStructuredOutput(result);
|
return normalizeAgentOutput(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractAssistantText(message: unknown): string | null {
|
export function extractAssistantText(message: unknown): string | null {
|
||||||
|
|||||||
@@ -75,6 +75,60 @@ 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('normalizes markdown image output and preserves attachments', () => {
|
||||||
|
expect(
|
||||||
|
buildSendMessageIpcPayload({
|
||||||
|
chatJid: 'dc:123',
|
||||||
|
text: `TASK_DONE
|
||||||
|
|
||||||
|
스크린샷입니다.
|
||||||
|
`,
|
||||||
|
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-markdown.png',
|
||||||
|
name: 'ejclaw-markdown.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({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
IPC_CLOSE_SENTINEL,
|
IPC_CLOSE_SENTINEL,
|
||||||
IPC_INPUT_SUBDIR,
|
IPC_INPUT_SUBDIR,
|
||||||
IPC_POLL_MS,
|
IPC_POLL_MS,
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeAgentOutput,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
type RunnerStructuredOutput,
|
type RunnerStructuredOutput,
|
||||||
} from 'ejclaw-runners-shared';
|
} from 'ejclaw-runners-shared';
|
||||||
@@ -89,7 +89,7 @@ function normalizeStructuredOutput(result: string | null): {
|
|||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerOutput['output'];
|
output?: RunnerOutput['output'];
|
||||||
} {
|
} {
|
||||||
return normalizeEjclawStructuredOutput(result);
|
return normalizeAgentOutput(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
function log(message: string): void {
|
function log(message: string): void {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
|||||||
|
|
||||||
export const IMAGE_TAG_RE =
|
export const IMAGE_TAG_RE =
|
||||||
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
|
/\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g;
|
||||||
|
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
||||||
|
const MARKDOWN_ABSOLUTE_LINK_RE = /!?\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||||
|
|
||||||
export const IPC_POLL_MS = 500;
|
export const IPC_POLL_MS = 500;
|
||||||
export const IPC_INPUT_SUBDIR = 'input';
|
export const IPC_INPUT_SUBDIR = 'input';
|
||||||
@@ -44,8 +46,16 @@ export type RunnerStructuredOutput =
|
|||||||
export interface NormalizedRunnerOutput {
|
export interface NormalizedRunnerOutput {
|
||||||
result: string | null;
|
result: string | null;
|
||||||
output?: RunnerStructuredOutput;
|
output?: RunnerStructuredOutput;
|
||||||
|
attachmentSource?:
|
||||||
|
| 'legacy-ejclaw-json'
|
||||||
|
| 'markdown-image'
|
||||||
|
| 'image-tag'
|
||||||
|
| 'mixed'
|
||||||
|
| 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NormalizedAgentOutput = NormalizedRunnerOutput;
|
||||||
|
|
||||||
function cloneImageTagPattern(): RegExp {
|
function cloneImageTagPattern(): RegExp {
|
||||||
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
|
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
|
||||||
}
|
}
|
||||||
@@ -74,6 +84,59 @@ export function extractImageTagPaths(text: string): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachmentName(filePath: string): string | undefined {
|
||||||
|
return filePath.split(/[\\/]/).at(-1) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueAttachments(
|
||||||
|
attachments: RunnerOutputAttachment[],
|
||||||
|
): RunnerOutputAttachment[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return attachments.filter((attachment) => {
|
||||||
|
if (seen.has(attachment.path)) return false;
|
||||||
|
seen.add(attachment.path);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMarkdownImageAttachments(text: string): {
|
||||||
|
cleanText: string;
|
||||||
|
attachments: RunnerOutputAttachment[];
|
||||||
|
} {
|
||||||
|
const attachments: RunnerOutputAttachment[] = [];
|
||||||
|
const cleanText = text.replace(
|
||||||
|
MARKDOWN_ABSOLUTE_LINK_RE,
|
||||||
|
(full: string, rawPath: string) => {
|
||||||
|
const trimmed = rawPath.trim();
|
||||||
|
if (!IMAGE_EXT_RE.test(trimmed)) return full;
|
||||||
|
|
||||||
|
attachments.push({
|
||||||
|
path: trimmed,
|
||||||
|
name: attachmentName(trimmed),
|
||||||
|
});
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanText: cleanText.trim(),
|
||||||
|
attachments: uniqueAttachments(attachments),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageTagPathsToAttachments(
|
||||||
|
imagePaths: string[],
|
||||||
|
): RunnerOutputAttachment[] {
|
||||||
|
return uniqueAttachments(
|
||||||
|
imagePaths
|
||||||
|
.filter((filePath) => IMAGE_EXT_RE.test(filePath))
|
||||||
|
.map((filePath) => ({
|
||||||
|
path: filePath,
|
||||||
|
name: attachmentName(filePath),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePublicTextOutput(
|
export function normalizePublicTextOutput(
|
||||||
result: string | null,
|
result: string | null,
|
||||||
): NormalizedRunnerOutput {
|
): NormalizedRunnerOutput {
|
||||||
@@ -103,6 +166,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 +207,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 +240,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 +279,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,
|
||||||
@@ -207,3 +299,62 @@ export function normalizeEjclawStructuredOutput(
|
|||||||
|
|
||||||
return normalizePublicTextOutput(result);
|
return normalizePublicTextOutput(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeAgentOutput(
|
||||||
|
result: string | null,
|
||||||
|
): NormalizedRunnerOutput {
|
||||||
|
const normalized = normalizeEjclawStructuredOutput(result);
|
||||||
|
if (
|
||||||
|
normalized.output?.visibility !== 'public' ||
|
||||||
|
typeof normalized.output.text !== 'string'
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitAttachments = normalized.output.attachments ?? [];
|
||||||
|
if (explicitAttachments.length > 0) {
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
attachmentSource: 'legacy-ejclaw-json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownExtracted = extractMarkdownImageAttachments(
|
||||||
|
normalized.output.text,
|
||||||
|
);
|
||||||
|
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
|
||||||
|
const imageTagAttachments = imageTagPathsToAttachments(
|
||||||
|
imageTagExtracted.imagePaths,
|
||||||
|
);
|
||||||
|
const attachments = uniqueAttachments([
|
||||||
|
...markdownExtracted.attachments,
|
||||||
|
...imageTagAttachments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (attachments.length === 0) {
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
attachmentSource: normalized.attachmentSource ?? 'none',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentSource =
|
||||||
|
markdownExtracted.attachments.length > 0 && imageTagAttachments.length > 0
|
||||||
|
? 'mixed'
|
||||||
|
: markdownExtracted.attachments.length > 0
|
||||||
|
? 'markdown-image'
|
||||||
|
: 'image-tag';
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: imageTagExtracted.cleanText,
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: imageTagExtracted.cleanText,
|
||||||
|
...(normalized.output.verdict
|
||||||
|
? { verdict: normalized.output.verdict }
|
||||||
|
: {}),
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
attachmentSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ export {
|
|||||||
type RoomRoleContext,
|
type RoomRoleContext,
|
||||||
} from './room-role-context.js';
|
} from './room-role-context.js';
|
||||||
export {
|
export {
|
||||||
|
extractMarkdownImageAttachments,
|
||||||
extractImageTagPaths,
|
extractImageTagPaths,
|
||||||
IMAGE_TAG_RE,
|
IMAGE_TAG_RE,
|
||||||
IPC_CLOSE_SENTINEL,
|
IPC_CLOSE_SENTINEL,
|
||||||
IPC_INPUT_SUBDIR,
|
IPC_INPUT_SUBDIR,
|
||||||
IPC_POLL_MS,
|
IPC_POLL_MS,
|
||||||
|
normalizeAgentOutput,
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeEjclawStructuredOutput,
|
||||||
normalizePublicTextOutput,
|
normalizePublicTextOutput,
|
||||||
OUTPUT_END_MARKER,
|
OUTPUT_END_MARKER,
|
||||||
OUTPUT_START_MARKER,
|
OUTPUT_START_MARKER,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
type NormalizedRunnerOutput,
|
type NormalizedRunnerOutput,
|
||||||
|
type NormalizedAgentOutput,
|
||||||
type RunnerOutputPhase,
|
type RunnerOutputPhase,
|
||||||
type RunnerOutputAttachment,
|
type RunnerOutputAttachment,
|
||||||
type RunnerOutputVerdict,
|
type RunnerOutputVerdict,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
121
runners/shared/test/normalize-agent-output.test.ts
Normal file
121
runners/shared/test/normalize-agent-output.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractMarkdownImageAttachments,
|
||||||
|
normalizeAgentOutput,
|
||||||
|
} from '../src/agent-protocol.js';
|
||||||
|
|
||||||
|
describe('normalizeAgentOutput', () => {
|
||||||
|
it('extracts markdown image attachments without rewriting normal links', () => {
|
||||||
|
expect(
|
||||||
|
extractMarkdownImageAttachments(
|
||||||
|
'결과입니다.\n\n[code](/tmp/source.ts#L10)',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
cleanText: '결과입니다.\n\n[code](/tmp/source.ts#L10)',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/result.png',
|
||||||
|
name: 'result.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes markdown image output into internal attachments', () => {
|
||||||
|
expect(
|
||||||
|
normalizeAgentOutput(
|
||||||
|
'TASK_DONE\n\n스크린샷입니다.\n',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
result: 'TASK_DONE\n\n스크린샷입니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'TASK_DONE\n\n스크린샷입니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/screenshot.png',
|
||||||
|
name: 'screenshot.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachmentSource: 'markdown-image',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes legacy image tags into internal attachments', () => {
|
||||||
|
expect(
|
||||||
|
normalizeAgentOutput(
|
||||||
|
'TASK_DONE\n\n스크린샷입니다.\n[Image: screenshot.png → /tmp/legacy.png]',
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
result: 'TASK_DONE\n\n스크린샷입니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'TASK_DONE\n\n스크린샷입니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/legacy.png',
|
||||||
|
name: 'legacy.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachmentSource: 'image-tag',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes short image tags documented in prompts', () => {
|
||||||
|
expect(
|
||||||
|
normalizeAgentOutput('TASK_DONE\n\n[Image: /tmp/short-form.png]'),
|
||||||
|
).toEqual({
|
||||||
|
result: 'TASK_DONE',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: 'TASK_DONE',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/short-form.png',
|
||||||
|
name: 'short-form.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachmentSource: 'image-tag',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps legacy ejclaw JSON as compatibility input', () => {
|
||||||
|
expect(
|
||||||
|
normalizeAgentOutput(
|
||||||
|
JSON.stringify({
|
||||||
|
ejclaw: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '이미지를 첨부했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/compat.png',
|
||||||
|
name: 'compat.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
result: '이미지를 첨부했습니다.',
|
||||||
|
output: {
|
||||||
|
visibility: 'public',
|
||||||
|
text: '이미지를 첨부했습니다.',
|
||||||
|
verdict: 'done',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/compat.png',
|
||||||
|
name: 'compat.png',
|
||||||
|
mime: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
attachmentSource: 'legacy-ejclaw-json',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
export {
|
export {
|
||||||
|
extractMarkdownImageAttachments,
|
||||||
extractImageTagPaths,
|
extractImageTagPaths,
|
||||||
IMAGE_TAG_RE,
|
IMAGE_TAG_RE,
|
||||||
IPC_CLOSE_SENTINEL,
|
IPC_CLOSE_SENTINEL,
|
||||||
IPC_INPUT_SUBDIR,
|
IPC_INPUT_SUBDIR,
|
||||||
IPC_POLL_MS,
|
IPC_POLL_MS,
|
||||||
|
normalizeAgentOutput,
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeEjclawStructuredOutput,
|
||||||
normalizePublicTextOutput,
|
normalizePublicTextOutput,
|
||||||
OUTPUT_END_MARKER,
|
OUTPUT_END_MARKER,
|
||||||
OUTPUT_START_MARKER,
|
OUTPUT_START_MARKER,
|
||||||
writeProtocolOutput,
|
writeProtocolOutput,
|
||||||
type NormalizedRunnerOutput,
|
type NormalizedRunnerOutput,
|
||||||
|
type NormalizedAgentOutput,
|
||||||
type RunnerOutputAttachment,
|
type RunnerOutputAttachment,
|
||||||
type RunnerOutputPhase,
|
type RunnerOutputPhase,
|
||||||
type RunnerOutputVerdict,
|
type RunnerOutputVerdict,
|
||||||
|
|||||||
@@ -1,64 +1,32 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import {
|
import { normalizeAgentOutput } from '../agent-protocol.js';
|
||||||
extractImageTagPaths,
|
|
||||||
normalizeEjclawStructuredOutput,
|
|
||||||
} from '../agent-protocol.js';
|
|
||||||
import type { OutboundAttachment } from '../types.js';
|
import type { OutboundAttachment } from '../types.js';
|
||||||
|
|
||||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
|
const LOCAL_MARKDOWN_LINK_RE = /\[[^\]\n]*\]\((\/[^)\n]+)\)/g;
|
||||||
const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g;
|
|
||||||
|
|
||||||
export interface PreparedDiscordOutbound {
|
export interface PreparedDiscordOutbound {
|
||||||
text: string;
|
text: string;
|
||||||
cleanText: string;
|
cleanText: string;
|
||||||
attachments: OutboundAttachment[];
|
attachments: OutboundAttachment[];
|
||||||
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'none';
|
attachmentSource: 'structured' | 'md-link' | 'image-tag' | 'mixed' | 'none';
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMarkdownImageAttachments(text: string): {
|
function sanitizeLocalMarkdownLinks(text: string): string {
|
||||||
cleanText: string;
|
return text.replace(LOCAL_MARKDOWN_LINK_RE, (_full, rawPath: string) => {
|
||||||
attachments: OutboundAttachment[];
|
|
||||||
} {
|
|
||||||
const attachments: OutboundAttachment[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => {
|
|
||||||
const trimmed = rawPath.trim();
|
const trimmed = rawPath.trim();
|
||||||
if (IMAGE_EXTS.test(trimmed)) {
|
|
||||||
if (!seen.has(trimmed)) {
|
|
||||||
attachments.push({
|
|
||||||
path: trimmed,
|
|
||||||
name: path.basename(trimmed),
|
|
||||||
});
|
|
||||||
seen.add(trimmed);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
const basename = path.basename(trimmed.replace(/#.*$/, ''));
|
||||||
const lineMatch = trimmed.match(/#L(\d+)/);
|
const lineMatch = trimmed.match(/#L(\d+)/);
|
||||||
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { cleanText, attachments };
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] {
|
|
||||||
return paths
|
|
||||||
.filter((filePath) => IMAGE_EXTS.test(filePath))
|
|
||||||
.map((filePath) => ({
|
|
||||||
path: filePath,
|
|
||||||
name: path.basename(filePath),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareDiscordOutbound(
|
export function prepareDiscordOutbound(
|
||||||
text: string,
|
text: string,
|
||||||
optionAttachments: OutboundAttachment[] | undefined,
|
optionAttachments: OutboundAttachment[] | undefined,
|
||||||
): PreparedDiscordOutbound {
|
): PreparedDiscordOutbound {
|
||||||
const normalized = normalizeEjclawStructuredOutput(text);
|
const normalized = normalizeAgentOutput(text);
|
||||||
if (normalized.output?.visibility === 'silent') {
|
if (normalized.output?.visibility === 'silent') {
|
||||||
return {
|
return {
|
||||||
text: '',
|
text: '',
|
||||||
@@ -72,30 +40,26 @@ export function prepareDiscordOutbound(
|
|||||||
const structuredOutput =
|
const structuredOutput =
|
||||||
normalized.output?.visibility === 'public' ? normalized.output : null;
|
normalized.output?.visibility === 'public' ? normalized.output : null;
|
||||||
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
const outboundText = structuredOutput?.text ?? normalized.result ?? text;
|
||||||
const structuredAttachments =
|
const cleanText = sanitizeLocalMarkdownLinks(outboundText);
|
||||||
|
const attachments =
|
||||||
optionAttachments && optionAttachments.length > 0
|
optionAttachments && optionAttachments.length > 0
|
||||||
? optionAttachments
|
? optionAttachments
|
||||||
: (structuredOutput?.attachments ?? []);
|
: (structuredOutput?.attachments ?? []);
|
||||||
const hasStructuredAttachments = structuredAttachments.length > 0;
|
const hasAttachments = attachments.length > 0;
|
||||||
const markdownExtracted = extractMarkdownImageAttachments(outboundText);
|
const attachmentSource =
|
||||||
const imageTagExtracted = extractImageTagPaths(markdownExtracted.cleanText);
|
optionAttachments && optionAttachments.length > 0
|
||||||
const legacyImageTagAttachments = imageTagPathsToAttachments(
|
? 'structured'
|
||||||
imageTagExtracted.imagePaths,
|
: normalized.attachmentSource === 'legacy-ejclaw-json'
|
||||||
);
|
? 'structured'
|
||||||
|
: normalized.attachmentSource === 'markdown-image'
|
||||||
|
? 'md-link'
|
||||||
|
: (normalized.attachmentSource ?? 'none');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: outboundText,
|
text: outboundText,
|
||||||
cleanText: imageTagExtracted.cleanText,
|
cleanText,
|
||||||
attachments: hasStructuredAttachments
|
attachments,
|
||||||
? structuredAttachments
|
attachmentSource: hasAttachments ? attachmentSource : 'none',
|
||||||
: [...markdownExtracted.attachments, ...legacyImageTagAttachments],
|
|
||||||
attachmentSource: hasStructuredAttachments
|
|
||||||
? 'structured'
|
|
||||||
: markdownExtracted.attachments.length > 0
|
|
||||||
? 'md-link'
|
|
||||||
: legacyImageTagAttachments.length > 0
|
|
||||||
? 'image-tag'
|
|
||||||
: 'none',
|
|
||||||
silent: false,
|
silent: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,4 +173,109 @@ 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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes markdown images and sends them as files', async () => {
|
||||||
|
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||||
|
await channel.connect();
|
||||||
|
const filePath = path.join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`ejclaw-discord-markdown-image-${Date.now()}.png`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(filePath, ONE_PIXEL_PNG);
|
||||||
|
tempFiles.push(filePath);
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-3' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage(
|
||||||
|
'dc:1234567890123456',
|
||||||
|
`스크린샷입니다.\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
|
content: '스크린샷입니다.',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
attachment: fs.realpathSync(filePath),
|
||||||
|
name: path.basename(filePath),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flags: 1 << 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-image local markdown links readable without uploading files', async () => {
|
||||||
|
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||||
|
await channel.connect();
|
||||||
|
const mockChannel = {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: 'discord-message-4' }),
|
||||||
|
sendTyping: vi.fn(),
|
||||||
|
};
|
||||||
|
clientRef.current.channels.fetch.mockResolvedValue(mockChannel);
|
||||||
|
|
||||||
|
await channel.sendMessage(
|
||||||
|
'dc:1234567890123456',
|
||||||
|
'수정 위치: [source](/tmp/project/src/index.ts#L42)',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||||
|
content: '수정 위치: `index.ts:42`',
|
||||||
|
files: undefined,
|
||||||
|
flags: 1 << 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
IpcMessageForwardResult,
|
IpcMessageForwardResult,
|
||||||
IpcMessagePayload,
|
IpcMessagePayload,
|
||||||
} from './ipc-types.js';
|
} from './ipc-types.js';
|
||||||
import { normalizeEjclawStructuredOutput } from './agent-protocol.js';
|
import { normalizeAgentOutput } from './agent-protocol.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
export async function forwardAuthorizedIpcMessage(
|
export async function forwardAuthorizedIpcMessage(
|
||||||
@@ -81,7 +81,7 @@ export async function forwardAuthorizedIpcMessage(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeEjclawStructuredOutput(msg.text);
|
const normalized = normalizeAgentOutput(msg.text);
|
||||||
if (normalized.output?.visibility === 'silent') {
|
if (normalized.output?.visibility === 'silent') {
|
||||||
return {
|
return {
|
||||||
outcome: 'sent',
|
outcome: 'sent',
|
||||||
|
|||||||
@@ -152,4 +152,39 @@ describe('web dashboard attachment data', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('turns markdown image output into dashboard attachments', () => {
|
||||||
|
const message: NewMessage = {
|
||||||
|
id: 'msg-markdown-image',
|
||||||
|
chat_jid: 'dc:ops',
|
||||||
|
sender: 'bot-1',
|
||||||
|
sender_name: 'owner',
|
||||||
|
content:
|
||||||
|
'라벨 좌측 클리핑 회귀 수정했습니다.\n',
|
||||||
|
timestamp: '2026-04-26T05:31:00.000Z',
|
||||||
|
is_from_me: true,
|
||||||
|
is_bot_message: true,
|
||||||
|
message_source_kind: 'bot',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activity = buildWebDashboardRoomActivity({
|
||||||
|
serviceId: 'codex-main',
|
||||||
|
entry: roomEntry,
|
||||||
|
pairedTask: null,
|
||||||
|
turns: [],
|
||||||
|
attempts: [],
|
||||||
|
outputs: [],
|
||||||
|
messages: [message],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(activity.messages[0]).toMatchObject({
|
||||||
|
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
path: '/tmp/bar-chart-label-fit-playwright.png',
|
||||||
|
name: 'bar-chart-label-fit-playwright.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
extractImageTagPaths,
|
extractImageTagPaths,
|
||||||
normalizeEjclawStructuredOutput,
|
normalizeAgentOutput,
|
||||||
} from './agent-protocol.js';
|
} from './agent-protocol.js';
|
||||||
import { isWatchCiTask } from './task-watch-status.js';
|
import { isWatchCiTask } from './task-watch-status.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -228,17 +228,18 @@ function normalizeStructuredVisibleContent(value: string): {
|
|||||||
text: string | null;
|
text: string | null;
|
||||||
attachments: OutboundAttachment[];
|
attachments: OutboundAttachment[];
|
||||||
} {
|
} {
|
||||||
if (!hasStructuredOutputHint(value)) return splitLegacyImageTags(value);
|
const candidates = hasStructuredOutputHint(value)
|
||||||
|
? [value, decodeCommonHtmlEntities(value)]
|
||||||
const candidates = [value, decodeCommonHtmlEntities(value)];
|
: [value];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const normalized = normalizeEjclawStructuredOutput(candidate);
|
const normalized = normalizeAgentOutput(candidate);
|
||||||
if (normalized.output?.visibility === 'silent') {
|
if (normalized.output?.visibility === 'silent') {
|
||||||
return { text: null, attachments: [] };
|
return { text: null, attachments: [] };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
normalized.output?.visibility === 'public' &&
|
normalized.output?.visibility === 'public' &&
|
||||||
normalized.output.text !== candidate
|
(normalized.output.text !== candidate ||
|
||||||
|
(normalized.output.attachments?.length ?? 0) > 0)
|
||||||
) {
|
) {
|
||||||
return splitLegacyImageTags(
|
return splitLegacyImageTags(
|
||||||
normalized.output.text,
|
normalized.output.text,
|
||||||
|
|||||||
Reference in New Issue
Block a user