import { describe, expect, it } from "bun:test"; import { finalizeSubprocessOutput, SUBAGENT_WARNING_MISSING_YIELD, SUBAGENT_WARNING_NULL_YIELD, SUBAGENT_WARNING_SCHEMA_OVERRIDDEN, } from "@oh-my-pi/pi-coding-agent/task/executor"; describe("subagent warning injection", () => { it("injects null-data warning when yield is success without data", () => { const result = finalizeSubprocessOutput({ rawOutput: "partial output", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success" }], outputSchema: undefined, }); expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_NULL_YIELD}\n\npartial output`); expect(result.hasYield).toBe(true); }); it("injects missing-submit warning when subagent exits cleanly without yield", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: undefined, outputSchema: { properties: { ok: { type: "boolean" } } }, }); expect(result.rawOutput).toBe(SUBAGENT_WARNING_MISSING_YIELD); expect(result.hasYield).toBe(false); }); it("does not inject missing-submit warning when fallback completion is recoverable", () => { const result = finalizeSubprocessOutput({ rawOutput: '{"data":{"ok":true}}', exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: undefined, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, }); expect(result.rawOutput).toBe('{\n "ok": true\n}'); expect(result.rawOutput.includes("SYSTEM WARNING")).toBe(false); }); it("prefixes missing-submit warning on stop outputs", () => { const result = finalizeSubprocessOutput({ rawOutput: "agent stopped after writing analysis", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: undefined, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, }); expect(result.rawOutput).toBe(`${SUBAGENT_WARNING_MISSING_YIELD}\n\nagent stopped after writing analysis`); }); it("does not inject missing-submit warning when execution exits non-zero", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 1, stderr: "subagent terminated", doneAborted: true, signalAborted: false, yieldItems: undefined, outputSchema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, }); expect(result.rawOutput).toBe(""); expect(result.stderr).toBe("subagent terminated"); expect(result.exitCode).toBe(1); }); it("normalizes explicit aborted yield into aborted payload", () => { const result = finalizeSubprocessOutput({ rawOutput: "partial output", exitCode: 1, stderr: "old error", doneAborted: false, signalAborted: false, yieldItems: [{ status: "aborted", error: "blocked by permissions" }], outputSchema: undefined, }); expect(result.abortedViaYield).toBe(true); expect(result.exitCode).toBe(0); expect(result.stderr).toBe("blocked by permissions"); expect(result.rawOutput).toContain('"aborted": true'); expect(result.rawOutput).toContain('"blocked by permissions"'); }); it("accepts successful yield data without warning", () => { const result = finalizeSubprocessOutput({ rawOutput: "should be replaced", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", data: { ok: true } }], outputSchema: undefined, }); expect(result.rawOutput).toBe('{\n "ok": true\n}'); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(result.rawOutput.includes("SYSTEM WARNING")).toBe(false); }); it("does not inject missing-submit warning when no schema and raw text exists", () => { const result = finalizeSubprocessOutput({ rawOutput: "plain text notes", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: undefined, outputSchema: undefined, }); expect(result.rawOutput).toBe("plain text notes"); expect(result.rawOutput.includes("SYSTEM WARNING")).toBe(false); expect(result.exitCode).toBe(0); }); it("honors schemaOverridden flag from yield and surfaces data with warning", () => { // Reviewer subagent exhausted its in-tool schema-retry budget, then was // accepted with empty finding objects. Without honoring the override, the // executor's post-mortem validator silently rejected the same payload with // `schema_violation`, opaquely swapping the agent's accepted output for an // error blob. Reports #2, #8, #11, #16, #17, #20. const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", data: { findings: [{}, {}] }, schemaOverridden: true }], outputSchema: { type: "object", required: ["findings"], properties: { findings: { type: "array", minItems: 1, items: { type: "object", required: ["severity", "file", "line"], properties: { severity: { type: "string" }, file: { type: "string" }, line: { type: "number" }, }, }, }, }, }, }); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(SUBAGENT_WARNING_SCHEMA_OVERRIDDEN); expect(JSON.parse(result.rawOutput)).toEqual({ findings: [{}, {}] }); }); it("treats malformed output schemas as no validation instead of schema_violation", () => { // Empty-string schema is a caller mistake; the yield tool already degrades // to a loose schema and accepts the data. The executor's finalizer used to // emit `schema_violation: invalid output schema` even though yield accepted // it, which surprised users dispatching prose review batches. Report #60. const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", data: { verdict: "looks good" } }], outputSchema: "", }); expect(result.exitCode).toBe(0); expect(JSON.parse(result.rawOutput)).toEqual({ verdict: "looks good" }); expect(result.stderr.startsWith("invalid output schema:")).toBe(true); }); it("assembles incremental typed yield sections on idle", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [ { status: "success", type: ["summary"], data: "first" }, { status: "success", type: ["summary", "notes"], data: { detail: "second" } }, ], outputSchema: { type: "object", required: ["summary", "notes"], properties: { summary: { type: "array", minItems: 2 }, notes: { type: "object", required: ["detail"], properties: { detail: { type: "string" } } }, }, }, }); expect(result.exitCode).toBe(0); expect(result.hasYield).toBe(true); expect(JSON.parse(result.rawOutput)).toEqual({ summary: ["first", { detail: "second" }], notes: { detail: "second" }, }); }); it("validates assembled typed yield data against the output schema", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", type: ["summary"], data: "text only" }], outputSchema: { type: "object", required: ["summary", "notes"], properties: { summary: { type: "string" }, notes: { type: "string" }, }, }, }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("schema_violation"); expect(JSON.parse(result.rawOutput)).toMatchObject({ error: "schema_violation", missingRequired: ["notes"], }); }); it("assembles a single incremental array-typed section into a one-element list", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [ { status: "success", type: ["findings"], data: { title: "Handle null response", body: "Crashes" } }, { status: "success", type: ["overall_correctness"], data: "incorrect" }, { status: "success", type: ["explanation"], data: "One bug blocks approval." }, { status: "success", type: ["confidence"], data: 0.8 }, ], // JTD reviewer-style schema: only `findings` is array-valued (elements). outputSchema: { properties: { overall_correctness: { enum: ["correct", "incorrect"] }, explanation: { type: "string" }, confidence: { type: "number" }, }, optionalProperties: { findings: { elements: { properties: { title: { type: "string" }, body: { type: "string" } } }, }, }, }, }); expect(result.exitCode).toBe(0); expect(result.stderr).toBe(""); expect(JSON.parse(result.rawOutput)).toEqual({ findings: [{ title: "Handle null response", body: "Crashes" }], overall_correctness: "incorrect", explanation: "One bug blocks approval.", confidence: 0.8, }); }); it("uses last assistant text as the raw result for terminal string-typed yields without data", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", type: "final" }], outputSchema: undefined, lastAssistantText: "final answer from the assistant", }); expect(result.exitCode).toBe(0); expect(result.rawOutput).toBe("final answer from the assistant"); }); it("keeps accumulated sections when a data-less terminal yield finalizes", () => { // Previously a data-less `type: "final"` collapsed to the last assistant // turn and dropped earlier incremental sections; the sections are the work // product, so they must survive the finalize. const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [ { status: "success", type: ["summary"], data: "first" }, { status: "success", type: "final" }, ], outputSchema: undefined, lastAssistantText: "plain final answer", }); expect(result.exitCode).toBe(0); expect(JSON.parse(result.rawOutput)).toEqual({ summary: "first" }); }); it("finalizes a string-typed terminal yield that carries data as the top-level result", () => { // Regression: a `type: "result"` finalize with the full structured object // was treated as a section labeled "result", nesting the payload one level // deep so every required field read as missing (schema_violation). const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [ { status: "success", type: "result", data: { summary: "did it", filesChanged: ["a.ts"], notes: ["ok"] }, }, ], outputSchema: { properties: { summary: { type: "string" }, filesChanged: { elements: { type: "string" } }, notes: { elements: { type: "string" } }, }, }, lastAssistantText: "some prose that must not become the result", }); expect(result.stderr).not.toContain("schema_violation"); expect(result.exitCode).toBe(0); expect(JSON.parse(result.rawOutput)).toEqual({ summary: "did it", filesChanged: ["a.ts"], notes: ["ok"], }); }); it("serializes untyped useLastTurn yield as raw text", () => { const result = finalizeSubprocessOutput({ rawOutput: "", exitCode: 0, stderr: "", doneAborted: false, signalAborted: false, yieldItems: [{ status: "success", useLastTurn: true }], outputSchema: undefined, lastAssistantText: "plain final answer", }); expect(result.exitCode).toBe(0); expect(result.rawOutput).toBe("plain final answer"); }); });