feat(agent): generate Skill drafts from conversations and canvases

This commit is contained in:
yu
2026-08-05 02:45:59 +08:00
parent 08deda5b3f
commit f01cb889ef
7 changed files with 948 additions and 29 deletions
+416
View File
@@ -2,10 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";
import { CodexAppClient } from "./codex-client.js";
import { assertDraftHasNoSensitiveValues, canvasSkillSource } from "./codex.js";
type TestClient = {
currentThreadId: string;
currentTurnId: string;
skillDraftActive: boolean;
completedTurns: Map<string, Error | null>;
plansByTurn: Map<string, unknown>;
lastUsage: unknown;
@@ -55,6 +57,14 @@ test("中断请求只作用于当前运行线程", async () => {
assert.ok(request);
testClient.handle({ id: request.id, result: {} });
assert.equal(await interrupt, true);
testClient.currentThreadId = "draft-thread";
testClient.currentTurnId = "draft-turn";
const draftInterrupt = client.interruptCurrentTurn();
const draftRequest = writes.at(-1);
assert.deepEqual(draftRequest?.params, { threadId: "draft-thread", turnId: "draft-turn" });
testClient.handle({ id: draftRequest?.id, result: {} });
assert.equal(await draftInterrupt, true);
});
test("Skill 列表与启用配置使用 app-server 原生协议", async () => {
@@ -140,6 +150,412 @@ test("skills/changed 作为站点级事件单独广播", () => {
assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]);
});
test("静默 Skill 草稿 turn 只返回结构化结果,不广播也不写历史", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const persistedItems: unknown[] = [];
const persistedTurns: unknown[] = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const history = {
record: (entry: unknown) => (persistedItems.push(entry), Promise.resolve()),
recordTurn: (entry: unknown) => (persistedTurns.push(entry), Promise.resolve()),
};
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
const threadRequest = writes.find((item) => item.method === "thread/start");
assert.ok(threadRequest);
testClient.handleNotification("thread/started", { thread: { id: "draft-thread", ephemeral: true } });
testClient.handle({ id: threadRequest.id, result: { thread: { id: "draft-thread", ephemeral: true } } });
await starting;
const schema = { type: "object", properties: { name: { type: "string" } } };
const output = JSON.stringify({ name: "product-image-flow" });
const generating = client.generateSkillDraft("draft-thread", "提炼流程", schema);
const turnRequest = writes.find((item) => item.method === "turn/start");
assert.deepEqual(turnRequest?.params, {
threadId: "draft-thread",
input: [{ type: "text", text: "提炼流程", text_elements: [] }],
approvalPolicy: "never",
sandboxPolicy: { type: "readOnly", networkAccess: false },
outputSchema: schema,
});
testClient.handle({ id: turnRequest?.id, result: { turn: { id: "draft-turn" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.handleNotification("turn/started", { threadId: "draft-thread", turn: { id: "draft-turn", status: "inProgress" } });
testClient.handleNotification("item/reasoning/summaryTextDelta", { threadId: "draft-thread", turnId: "draft-turn", itemId: "reasoning-1", delta: "正在提炼" });
testClient.handleNotification("item/completed", { threadId: "draft-thread", turnId: "draft-turn", item: { id: "reasoning-1", type: "reasoning", summary: ["正在提炼"] } });
testClient.handleNotification("item/agentMessage/delta", { threadId: "draft-thread", turnId: "draft-turn", itemId: "assistant-1", delta: output });
testClient.handleNotification("item/completed", { threadId: "draft-thread", turnId: "draft-turn", item: { id: "assistant-1", type: "agentMessage", text: "" } });
testClient.handleNotification("turn/completed", { threadId: "draft-thread", turn: { id: "draft-turn", status: "completed" } });
assert.equal(await generating, output);
assert.deepEqual(events, []);
assert.deepEqual(persistedItems, []);
assert.deepEqual(persistedTurns, []);
assert.equal(testClient.skillDraftActive, true);
const closing = client.closeSkillDraftThread("draft-thread");
const unsubscribe = writes.find((item) => item.method === "thread/unsubscribe");
assert.ok(unsubscribe);
testClient.handle({ id: unsubscribe.id, result: { status: "unsubscribed" } });
await closing;
assert.equal(testClient.skillDraftActive, false);
});
test("静默线程创建期间只隐藏响应返回的线程", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
const request = writes.find((item) => item.method === "thread/start");
assert.ok(request);
testClient.handleNotification("thread/started", { thread: { id: "normal-thread" } });
testClient.handleNotification("thread/started", { thread: { id: "draft-thread", ephemeral: true } });
assert.deepEqual(events, []);
testClient.handle({ id: request.id, result: { thread: { id: "draft-thread", ephemeral: true } } });
await starting;
assert.deepEqual(events, [{
type: "agent_event",
payload: { agent: "codex", type: "thread.started", thread_id: "normal-thread" },
}]);
testClient.handleNotification("thread/started", { thread: { id: "draft-thread", ephemeral: true } });
assert.equal(events.length, 1);
});
test("静默 Skill 草稿不吞掉全局 Skill 变更通知", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
const request = writes.find((item) => item.method === "thread/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { thread: { id: "draft-thread", ephemeral: true } } });
await starting;
testClient.handleNotification("thread/started", { thread: { id: "draft-thread", ephemeral: true } });
testClient.handleNotification("skills/changed", {});
assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]);
});
test("静默 Skill 草稿自动拒绝未携带线程 ID 的权限请求", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
const request = writes.find((item) => item.method === "thread/start");
assert.ok(request);
testClient.handle({ id: request.id, result: { thread: { id: "draft-thread", ephemeral: true } } });
await starting;
testClient.handleNotification("turn/started", { threadId: "draft-thread", turn: { id: "draft-turn", status: "inProgress" } });
testClient.answerServerRequest({ id: 17, method: "item/permissions/requestApproval", params: { turnId: "draft-turn", permissions: { network: true } } });
assert.deepEqual(writes.at(-1), { id: 17, result: { permissions: {}, scope: "turn" } });
assert.deepEqual(events, []);
});
test("app-server 退出时清理尚未确认的静默线程", async () => {
const events: Array<{ type: string; payload: unknown }> = [];
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
testClient.handleNotification("thread/started", { thread: { id: "draft-thread", ephemeral: true } });
assert.equal(testClient.skillDraftActive, true);
testClient.failAll("Codex app-server exited: 1");
assert.equal(testClient.skillDraftActive, false);
assert.deepEqual(events, []);
await assert.rejects(starting, /Codex app-server exited/);
});
test("app-server 在草稿运行中退出后不再等待线程取消订阅", async () => {
const writes: Array<Record<string, unknown>> = [];
const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } };
const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient;
const testClient = client as unknown as TestClient;
const starting = client.startSkillDraftThread("D:\\site");
const threadRequest = writes.find((item) => item.method === "thread/start");
assert.ok(threadRequest);
testClient.handle({ id: threadRequest.id, result: { thread: { id: "draft-thread", ephemeral: true } } });
await starting;
const generating = client.generateSkillDraft("draft-thread", "提炼流程", { type: "object" });
const turnRequest = writes.find((item) => item.method === "turn/start");
assert.ok(turnRequest);
testClient.handle({ id: turnRequest.id, result: { turn: { id: "draft-turn" } } });
await new Promise((resolve) => setImmediate(resolve));
testClient.failAll("Codex app-server exited: 1");
await assert.rejects(generating, /Codex app-server exited/);
const writeCount = writes.length;
await client.closeSkillDraftThread("draft-thread");
assert.equal(writes.length, writeCount);
await assert.rejects(client.listModels(), /Codex app-server exited/);
assert.equal(writes.length, writeCount);
});
test("画布 Skill 草稿源保留流程信息并移除媒体、本地路径、凭据和临时任务字段", () => {
const source = canvasSkillSource({
projectId: "private-project",
clientId: "private-client",
title: "商品图流程",
viewport: { x: 100, y: 200, k: 1.5 },
nodes: [{
id: "prompt-node",
type: "text",
title: "提示词",
position: { x: 20, y: 30 },
width: 480,
height: 240,
metadata: {
content: "保留这段可复用流程;API Key=secret-api-keyBearer abcdefghijklmnop",
sourceUrl: "https://example.com/reference.png",
extensionlessMediaUrl: "https://cdn.example.com/private-media/abc123",
sensitiveUrl: "https://example.com/reference.png?token=secret-value",
signedUrl: "https://example.com/reference.png?X-Amz-Signature=secret-value",
basicAuthUrl: "https://user:password@example.com/reference.png",
dataUrl: "data:image/png;base64,c2VjcmV0",
blobUrl: "blob:http://localhost/private-image",
reference: "D:\\private\\reference.png",
referenceNote: "本地文件 D:\\private\\reference.png 参考图 https://example.com/reference.png",
unixReference: "/var/lib/private/reference.png",
unixReferenceNote: "文件 /root/private.png 与 file:///opt/private.png",
batchRootId: "output-node",
batchChildIds: ["output-node", "missing-node"],
primaryImageId: "output-node",
groupId: "prompt-node",
storageKey: "canvas-private-key",
apiKey: "secret-api-key",
credentials: { token: "secret-token" },
status: "running",
progress: 75,
errorDetails: "temporary failure",
taskId: "task-private",
createdAt: "2026-08-03",
nested: { keep: "保留", password: "secret-password", preview: "blob:http://localhost/nested" },
},
}, {
id: "output-node",
type: "image",
title: "结果图片",
position: { x: 600, y: 30 },
width: 480,
height: 480,
metadata: { content: "data:image/png;base64,c2VjcmV0" },
}],
connections: [{ id: "connection-1", fromNodeId: "prompt-node", toNodeId: "output-node" }],
selectedNodeIds: ["prompt-node"],
});
assert.deepEqual(source, {
title: "商品图流程",
nodes: [{
ref: "node-1",
type: "text",
title: "提示词",
metadata: {
content: "保留这段可复用流程;[敏感凭证已移除];[敏感凭证已移除]",
referenceNote: "本地文件 [本地路径已移除] 参考图 [外部地址已移除]",
unixReferenceNote: "文件 [本地路径已移除] 与 [本地路径已移除]",
batchRootRef: "node-2",
batchChildRefs: ["node-2"],
primaryImageRef: "node-2",
groupRef: "node-1",
nested: { keep: "保留" },
},
}, {
ref: "node-2",
type: "image",
title: "结果图片",
}],
connections: [{ from: "node-1", to: "node-2" }],
selectedNodeRefs: ["node-1"],
});
assert.doesNotMatch(JSON.stringify(source), /prompt-node|output-node|connection-1|secret-value|secret-api-key|abcdefghijklmnop|D:\\\\private|\/var\/|\/root\/|\/opt\/|file:|https?:\/\//);
});
test("画布 Skill 草稿源保留普通中文斜杠文本和斜杠命令", () => {
const source = canvasSkillSource({
nodes: [{
id: "prompt-node",
type: "text",
title: "提示词",
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: {
content: "故事概念/剧本圣经 → 配音/音效/音乐",
prompt: "/imagine 商品图",
},
}],
connections: [],
});
assert.deepEqual(source, {
nodes: [{
ref: "node-1",
type: "text",
title: "提示词",
metadata: {
content: "故事概念/剧本圣经 → 配音/音效/音乐",
prompt: "/imagine 商品图",
},
}],
connections: [],
});
});
test("画布 Skill 草稿源匿名化任意字符串中的已知节点 ID", () => {
const imageNodeId = "8f36ab15-30f1-4c73-b169-f12c36584ddb";
const source = canvasSkillSource({
nodes: [{
id: "prompt-node",
type: "text",
title: "提示词",
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: { composerContent: `参考 @[node:${imageNodeId}] 生成商品图` },
}, {
id: imageNodeId,
type: "image",
title: "参考图",
position: { x: 400, y: 0 },
width: 320,
height: 320,
metadata: {},
}],
connections: [],
});
assert.equal(
((source.nodes as Array<Record<string, unknown>>)[0]?.metadata as Record<string, unknown>).composerContent,
"参考 @[node:node-2] 生成商品图",
);
assert.doesNotMatch(JSON.stringify(source), new RegExp(imageNodeId));
});
test("画布 Skill 草稿源匿名化 metadata 对象键中的已知节点 ID", () => {
const imageNodeId = "8f36ab15-30f1-4c73-b169-f12c36584ddb";
const source = canvasSkillSource({
nodes: [{
id: "prompt-node",
type: "text",
title: "提示词",
position: { x: 0, y: 0 },
width: 320,
height: 180,
metadata: { pluginState: { [`node:${imageNodeId}`]: { label: "参考图" } } },
}, {
id: imageNodeId,
type: "image",
title: "参考图",
position: { x: 400, y: 0 },
width: 320,
height: 320,
metadata: {},
}],
connections: [],
});
const metadata = (source.nodes as Array<Record<string, unknown>>)[0]?.metadata as Record<string, unknown>;
assert.deepEqual(metadata.pluginState, { "node:node-2": { label: "参考图" } });
assert.doesNotMatch(JSON.stringify(source), new RegExp(imageNodeId));
});
test("画布 Skill 草稿源为截断节点保留完整快照范围内的安全引用", () => {
const targetNodeId = "target-node-id";
const nodes = Array.from({ length: 301 }, (_, index) => ({
id: index === 300 ? targetNodeId : `filler-node-${index + 1}`,
type: "text" as const,
title: index === 0 ? "提示词" : `节点 ${index + 1}`,
position: { x: index * 10, y: 0 },
width: 320,
height: 180,
metadata: index === 0 ? { composerContent: `参考 @[node:${targetNodeId}]` } : {},
}));
const source = canvasSkillSource({ nodes, connections: [] });
const firstMetadata = (source.nodes as Array<Record<string, unknown>>)[0]?.metadata as Record<string, unknown>;
assert.equal(firstMetadata.composerContent, "参考 @[node:node-301]");
assert.equal(source.truncated, true);
assert.doesNotMatch(JSON.stringify(source), new RegExp(targetNodeId));
});
test("画布 Skill 草稿源限制总长度并优先保留选中流程", () => {
const nodes = Array.from({ length: 320 }, (_, index) => ({
id: `node-${index + 1}`,
type: "text" as const,
title: index === 318 ? "关联节点" : index === 319 ? "选中节点" : `普通节点 ${index + 1}`,
position: { x: index * 10, y: 0 },
width: 320,
height: 180,
metadata: { content: "流程说明".repeat(2_000) },
}));
const source = canvasSkillSource({
nodes,
connections: [{ id: "private-connection", fromNodeId: "node-319", toNodeId: "node-320" }],
selectedNodeIds: ["node-320"],
});
const sourceNodes = source.nodes as Array<Record<string, unknown>>;
assert.ok(JSON.stringify(source).length <= 120_000);
assert.equal(sourceNodes[0]?.title, "选中节点");
assert.equal(sourceNodes[1]?.title, "关联节点");
assert.deepEqual(source.connections, [{ from: "node-2", to: "node-1" }]);
assert.deepEqual(source.selectedNodeRefs, ["node-1"]);
assert.equal(source.truncated, true);
});
test("Skill 草稿拒绝普通外部 URL,避免对话内容泄露媒体地址", () => {
const draft = {
name: "product-image-flow",
displayName: "Product image flow",
description: "Reusable image generation workflow",
instructions: "参考图地址:https://example.com/reference.png",
shortDescription: "Generate product images from a reusable workflow",
defaultPrompt: "$product-image-flow",
};
assert.throws(() => assertDraftHasNoSensitiveValues(draft, []), /外部地址/);
});
test("Skill 草稿允许普通动作名称,但拒绝任意本地绝对路径", () => {
const draft = {
name: "request-review",
displayName: "Review request",
description: "Use node-workflow-builder to review a reusable flow",
instructions: "Run generation-status-check, then use job-application-helper.",
shortDescription: "Review a reusable workflow",
defaultPrompt: "$request-review",
};
assert.doesNotThrow(() => assertDraftHasNoSensitiveValues(draft, []));
assert.throws(() => assertDraftHasNoSensitiveValues({ ...draft, instructions: "读取 /var/lib/private.png" }, []), /本地路径/);
assert.throws(() => assertDraftHasNoSensitiveValues({ ...draft, instructions: "读取 file:///root/private.png" }, []), /本地路径/);
});
test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => {
const writes: Array<Record<string, unknown>> = [];
const events: Array<{ type: string; payload: unknown }> = [];