























@@ -76,6 +76,7 @@ function createFakeClient(options?: {
7676completeWithItems?: boolean;
7777notifyError?: string;
7878approvalRequestMethod?: string;
79+responseText?: string;
7980}) {
8081const notifications = new Set<(notification: CodexServerNotification) => void>();
8182const requestHandlers = new Set<(request: { method: string }) => JsonValue | undefined>();
@@ -125,7 +126,7 @@ function createFakeClient(options?: {
125126threadId: "thread-1",
126127turnId: "turn-1",
127128itemId: "msg-1",
128-delta: "A red square.",
129+delta: options?.responseText ?? "A red square.",
129130},
130131});
131132notify({
@@ -145,7 +146,7 @@ function createFakeClient(options?: {
145146{
146147id: "msg-1",
147148type: "agentMessage",
148-text: "A blue circle.",
149+text: options?.responseText ?? "A blue circle.",
149150phase: null,
150151memoryCitation: null,
151152},
@@ -298,4 +299,161 @@ describe("codex media understanding provider", () => {
298299}),
299300).rejects.toThrow("vision unavailable");
300301});
302+303+it("runs structured extraction through the same bounded Codex app-server path", async () => {
304+const { client, requests } = createFakeClient({
305+responseText: '{"summary":"red square","tags":["shape"]}',
306+});
307+const provider = buildCodexMediaUnderstandingProvider({
308+clientFactory: async () => client,
309+});
310+311+const result = await provider.extractStructured?.({
312+input: [
313+{ type: "text", text: "Extract searchable evidence." },
314+{
315+type: "image",
316+buffer: Buffer.from("image-bytes"),
317+fileName: "image.png",
318+mime: "image/png",
319+},
320+],
321+instructions: "Return a compact evidence object.",
322+schemaName: "example.media",
323+jsonSchema: {
324+type: "object",
325+properties: {
326+summary: { type: "string" },
327+tags: { type: "array", items: { type: "string" } },
328+},
329+required: ["summary"],
330+},
331+provider: "codex",
332+model: "gpt-5.4",
333+timeoutMs: 30_000,
334+cfg: {},
335+agentDir: "/tmp/openclaw-agent",
336+});
337+338+expect(result).toEqual({
339+text: '{"summary":"red square","tags":["shape"]}',
340+parsed: { summary: "red square", tags: ["shape"] },
341+model: "gpt-5.4",
342+provider: "codex",
343+contentType: "json",
344+});
345+expect(requests.map((entry) => entry.method)).toEqual([
346+"model/list",
347+"thread/start",
348+"turn/start",
349+]);
350+expect(requests[1]?.params).toMatchObject({
351+model: "gpt-5.4",
352+modelProvider: "openai",
353+approvalPolicy: "on-request",
354+sandbox: "read-only",
355+dynamicTools: [],
356+ephemeral: true,
357+persistExtendedHistory: false,
358+});
359+expect(requests[2]?.params).toMatchObject({
360+threadId: "thread-1",
361+approvalPolicy: "on-request",
362+model: "gpt-5.4",
363+input: [
364+expect.objectContaining({
365+type: "text",
366+text: expect.stringContaining("Return valid JSON only"),
367+}),
368+{ type: "text", text: "Extract searchable evidence.", text_elements: [] },
369+{ type: "image", url: "data:image/png;base64,aW1hZ2UtYnl0ZXM=" },
370+],
371+});
372+});
373+374+it("rejects text-only structured extraction before starting a turn", async () => {
375+const { client, requests } = createFakeClient({
376+inputModalities: ["text"],
377+responseText: '{"summary":"only text"}',
378+});
379+const provider = buildCodexMediaUnderstandingProvider({
380+clientFactory: async () => client,
381+});
382+383+await expect(
384+provider.extractStructured?.({
385+input: [{ type: "text", text: "The answer is only text." }],
386+instructions: "Return summary JSON.",
387+provider: "codex",
388+model: "gpt-5.4",
389+timeoutMs: 30_000,
390+cfg: {},
391+agentDir: "/tmp/openclaw-agent",
392+}),
393+).rejects.toThrow("Codex structured extraction requires at least one image input.");
394+expect(requests).toEqual([]);
395+});
396+397+it("returns a controlled error when structured JSON parsing fails", async () => {
398+const { client } = createFakeClient({ responseText: "not json" });
399+const provider = buildCodexMediaUnderstandingProvider({
400+clientFactory: async () => client,
401+});
402+403+await expect(
404+provider.extractStructured?.({
405+input: [
406+{ type: "text", text: "Extract JSON." },
407+{
408+type: "image",
409+buffer: Buffer.from("image-bytes"),
410+fileName: "image.png",
411+mime: "image/png",
412+},
413+],
414+instructions: "Return summary JSON.",
415+provider: "codex",
416+model: "gpt-5.4",
417+timeoutMs: 30_000,
418+cfg: {},
419+agentDir: "/tmp/openclaw-agent",
420+}),
421+).rejects.toThrow("Codex structured extraction returned invalid JSON.");
422+});
423+424+it("validates structured extraction JSON against the requested schema", async () => {
425+const { client } = createFakeClient({
426+responseText: '{"summary":123,"tags":["shape"]}',
427+});
428+const provider = buildCodexMediaUnderstandingProvider({
429+clientFactory: async () => client,
430+});
431+432+await expect(
433+provider.extractStructured?.({
434+input: [
435+{ type: "text", text: "Extract JSON." },
436+{
437+type: "image",
438+buffer: Buffer.from("image-bytes"),
439+fileName: "image.png",
440+mime: "image/png",
441+},
442+],
443+instructions: "Return summary JSON.",
444+jsonSchema: {
445+type: "object",
446+properties: {
447+summary: { type: "string" },
448+},
449+required: ["summary"],
450+},
451+provider: "codex",
452+model: "gpt-5.4",
453+timeoutMs: 30_000,
454+cfg: {},
455+agentDir: "/tmp/openclaw-agent",
456+}),
457+).rejects.toThrow("Codex structured extraction JSON did not match schema");
458+});
301459});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。