test(coding-agent/modes): updated controller test mocks
- Added missing `noteDisplayableThinkingContent` mock function to test fixtures. - Included `markActivityStart` and `markActivityEnd` methods in status line mocks to match updated controller interfaces.
This commit is contained in:
@@ -73,6 +73,7 @@ import {
|
||||
type TaskToolDetails,
|
||||
type YieldItem,
|
||||
} from "./types";
|
||||
import { arrayValuedLabels, assembleYieldResult } from "./yield-assembly";
|
||||
|
||||
export type { YieldItem } from "./types";
|
||||
|
||||
@@ -520,200 +521,6 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
|
||||
return { data: candidate };
|
||||
}
|
||||
|
||||
interface AssembledYieldResult {
|
||||
data: unknown;
|
||||
schemaOverridden: boolean;
|
||||
rawText: boolean;
|
||||
missingData: boolean;
|
||||
}
|
||||
|
||||
function isIncrementalYieldType(type: YieldItem["type"]): type is string[] {
|
||||
return Array.isArray(type) && type.length > 0;
|
||||
}
|
||||
|
||||
function getYieldLabels(type: YieldItem["type"]): string[] {
|
||||
if (typeof type === "string") {
|
||||
const label = type.trim();
|
||||
return label ? [label] : [];
|
||||
}
|
||||
if (!Array.isArray(type)) return [];
|
||||
const labels: string[] = [];
|
||||
for (const value of type) {
|
||||
if (typeof value !== "string") continue;
|
||||
const label = value.trim();
|
||||
if (label) labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function resolveYieldPayload(
|
||||
item: YieldItem,
|
||||
lastAssistantText: string | undefined,
|
||||
labels: string[],
|
||||
): { value: unknown; fromLastAssistantText: boolean; missingData: boolean } {
|
||||
const hasData = item.data !== undefined;
|
||||
const shouldUseLastTurn = item.useLastTurn === true || (labels.length > 0 && !hasData);
|
||||
if (shouldUseLastTurn && lastAssistantText !== undefined) {
|
||||
return {
|
||||
value: lastAssistantText,
|
||||
fromLastAssistantText: true,
|
||||
missingData: lastAssistantText.length === 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: item.data,
|
||||
fromLastAssistantText: false,
|
||||
missingData: item.data === undefined || item.data === null,
|
||||
};
|
||||
}
|
||||
|
||||
function appendYieldSection(
|
||||
sections: Record<string, unknown>,
|
||||
sectionCounts: Map<string, number>,
|
||||
label: string,
|
||||
value: unknown,
|
||||
forceArray: boolean,
|
||||
): void {
|
||||
const count = sectionCounts.get(label) ?? 0;
|
||||
const existing = sections[label];
|
||||
if (count === 0) {
|
||||
sections[label] = forceArray ? [value] : value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
sections[label] = [existing, value];
|
||||
}
|
||||
sectionCounts.set(label, count + 1);
|
||||
}
|
||||
|
||||
/** True when `value` is a JSON-schema node whose instances are arrays. */
|
||||
function isArrayTypedSchema(value: unknown): boolean {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "array") return true;
|
||||
if (Array.isArray(record.type) && record.type.includes("array")) return true;
|
||||
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
|
||||
const variants = record[key];
|
||||
if (Array.isArray(variants) && variants.some(isArrayTypedSchema)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level output-schema property names declared as arrays (JTD `elements` →
|
||||
* JSON `type: "array"`). An incremental yield section for such a label
|
||||
* accumulates into a list even when the agent emits exactly one — otherwise a
|
||||
* single `type: ["findings"]` yield would assemble as a bare object and fail
|
||||
* array-typed schema validation.
|
||||
*/
|
||||
function arrayValuedLabels(outputSchema: unknown): ReadonlySet<string> {
|
||||
const labels = new Set<string>();
|
||||
// Use the JTD-converted JSON Schema (matches what validation runs against):
|
||||
// JTD `optionalProperties.findings.elements` becomes `properties.findings`
|
||||
// with `type: "array"`, which raw `normalizeSchema` would not expose.
|
||||
const { jsonSchema } = buildOutputValidator(outputSchema);
|
||||
if (jsonSchema === undefined) return labels;
|
||||
const properties = jsonSchema.properties;
|
||||
if (properties === null || typeof properties !== "object") return labels;
|
||||
const propRecord = properties as Record<string, unknown>;
|
||||
for (const key in propRecord) {
|
||||
if (isArrayTypedSchema(propRecord[key])) labels.add(key);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble typed yield calls into the final payload consumed by schema validation.
|
||||
*
|
||||
* A non-empty array `type` contributes an incremental section and never decides
|
||||
* termination by itself. A string `type` with omitted `data` makes the last
|
||||
* assistant turn the raw terminal result. Other string-typed yields contribute
|
||||
* the terminal labelled section. Untyped terminal yields keep the historical
|
||||
* "last yield wins" behavior unless no terminal yield exists, in which case
|
||||
* accumulated typed sections finalize on idle.
|
||||
*/
|
||||
export function assembleYieldResult(
|
||||
yieldItems: YieldItem[],
|
||||
lastAssistantText?: string,
|
||||
arrayLabels?: ReadonlySet<string>,
|
||||
): AssembledYieldResult | undefined {
|
||||
if (yieldItems.length === 0) return undefined;
|
||||
let terminalItem: YieldItem | undefined;
|
||||
for (let index = yieldItems.length - 1; index >= 0; index--) {
|
||||
const item = yieldItems[index];
|
||||
if (!item) continue;
|
||||
if (!isIncrementalYieldType(item.type)) {
|
||||
terminalItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let hasTypedSections = false;
|
||||
for (const item of yieldItems) {
|
||||
if (getYieldLabels(item.type).length > 0) {
|
||||
hasTypedSections = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) {
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
if (!hasTypedSections && terminalItem) {
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
|
||||
const sections: Record<string, unknown> = {};
|
||||
const sectionCounts = new Map<string, number>();
|
||||
let schemaOverridden = false;
|
||||
let missingData = false;
|
||||
let hasSections = false;
|
||||
|
||||
for (const item of yieldItems) {
|
||||
if (item.status === "aborted") continue;
|
||||
schemaOverridden ||= item.schemaOverridden === true;
|
||||
const labels = getYieldLabels(item.type);
|
||||
if (labels.length === 0) continue;
|
||||
const resolved = resolveYieldPayload(item, lastAssistantText, labels);
|
||||
missingData ||= resolved.missingData;
|
||||
const incremental = isIncrementalYieldType(item.type);
|
||||
for (const label of labels) {
|
||||
appendYieldSection(
|
||||
sections,
|
||||
sectionCounts,
|
||||
label,
|
||||
resolved.value,
|
||||
incremental && (arrayLabels?.has(label) ?? false),
|
||||
);
|
||||
hasSections = true;
|
||||
}
|
||||
if (!isIncrementalYieldType(item.type)) break;
|
||||
}
|
||||
|
||||
if (hasSections) {
|
||||
return { data: sections, schemaOverridden, rawText: false, missingData };
|
||||
}
|
||||
|
||||
if (!terminalItem) return undefined;
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
|
||||
interface FinalizeSubprocessOutputArgs {
|
||||
rawOutput: string;
|
||||
exitCode: number;
|
||||
|
||||
@@ -32,10 +32,10 @@ import {
|
||||
type SubmitReviewDetails,
|
||||
} from "../tools/review";
|
||||
import { framedBlock, renderStatusLine } from "../tui";
|
||||
import { assembleYieldResult } from "./executor";
|
||||
import { repairDoubleEncodedJsonString } from "./repair-args";
|
||||
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
||||
import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails, YieldItem } from "./types";
|
||||
import { assembleYieldResult } from "./yield-assembly";
|
||||
|
||||
/** Render context threaded in from `ToolExecutionComponent.#buildRenderContext`. */
|
||||
interface TaskRenderContext {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Pure assembly of subagent `yield` calls into the final payload consumed by
|
||||
* output-schema validation.
|
||||
*
|
||||
* Lives apart from the subagent runtime in `executor.ts` so the rendering path
|
||||
* (`render.ts` → `extractIncrementalReviewResult`) can assemble incremental
|
||||
* yields without importing that runtime's dependency graph (`sdk`,
|
||||
* `session-manager`, the TUI tool renderers). It has no side effects and
|
||||
* depends only on the yield type and the output-schema validator.
|
||||
*/
|
||||
import { buildOutputValidator } from "../tools/output-schema-validator";
|
||||
import type { YieldItem } from "./types";
|
||||
|
||||
/** Outcome of folding a run's yield calls into one payload, with provenance flags. */
|
||||
interface AssembledYieldResult {
|
||||
data: unknown;
|
||||
schemaOverridden: boolean;
|
||||
rawText: boolean;
|
||||
missingData: boolean;
|
||||
}
|
||||
|
||||
function isIncrementalYieldType(type: YieldItem["type"]): type is string[] {
|
||||
return Array.isArray(type) && type.length > 0;
|
||||
}
|
||||
|
||||
function getYieldLabels(type: YieldItem["type"]): string[] {
|
||||
if (typeof type === "string") {
|
||||
const label = type.trim();
|
||||
return label ? [label] : [];
|
||||
}
|
||||
if (!Array.isArray(type)) return [];
|
||||
const labels: string[] = [];
|
||||
for (const value of type) {
|
||||
if (typeof value !== "string") continue;
|
||||
const label = value.trim();
|
||||
if (label) labels.push(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function resolveYieldPayload(
|
||||
item: YieldItem,
|
||||
lastAssistantText: string | undefined,
|
||||
labels: string[],
|
||||
): { value: unknown; fromLastAssistantText: boolean; missingData: boolean } {
|
||||
const hasData = item.data !== undefined;
|
||||
const shouldUseLastTurn = item.useLastTurn === true || (labels.length > 0 && !hasData);
|
||||
if (shouldUseLastTurn && lastAssistantText !== undefined) {
|
||||
return {
|
||||
value: lastAssistantText,
|
||||
fromLastAssistantText: true,
|
||||
missingData: lastAssistantText.length === 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: item.data,
|
||||
fromLastAssistantText: false,
|
||||
missingData: item.data === undefined || item.data === null,
|
||||
};
|
||||
}
|
||||
|
||||
function appendYieldSection(
|
||||
sections: Record<string, unknown>,
|
||||
sectionCounts: Map<string, number>,
|
||||
label: string,
|
||||
value: unknown,
|
||||
forceArray: boolean,
|
||||
): void {
|
||||
const count = sectionCounts.get(label) ?? 0;
|
||||
const existing = sections[label];
|
||||
if (count === 0) {
|
||||
sections[label] = forceArray ? [value] : value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
sections[label] = [existing, value];
|
||||
}
|
||||
sectionCounts.set(label, count + 1);
|
||||
}
|
||||
|
||||
/** True when `value` is a JSON-schema node whose instances are arrays. */
|
||||
function isArrayTypedSchema(value: unknown): boolean {
|
||||
if (value === null || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "array") return true;
|
||||
if (Array.isArray(record.type) && record.type.includes("array")) return true;
|
||||
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
|
||||
const variants = record[key];
|
||||
if (Array.isArray(variants) && variants.some(isArrayTypedSchema)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level output-schema property names declared as arrays (JTD `elements` →
|
||||
* JSON `type: "array"`). An incremental yield section for such a label
|
||||
* accumulates into a list even when the agent emits exactly one — otherwise a
|
||||
* single `type: ["findings"]` yield would assemble as a bare object and fail
|
||||
* array-typed schema validation.
|
||||
*/
|
||||
export function arrayValuedLabels(outputSchema: unknown): ReadonlySet<string> {
|
||||
const labels = new Set<string>();
|
||||
// Use the JTD-converted JSON Schema (matches what validation runs against):
|
||||
// JTD `optionalProperties.findings.elements` becomes `properties.findings`
|
||||
// with `type: "array"`, which raw `normalizeSchema` would not expose.
|
||||
const { jsonSchema } = buildOutputValidator(outputSchema);
|
||||
if (jsonSchema === undefined) return labels;
|
||||
const properties = jsonSchema.properties;
|
||||
if (properties === null || typeof properties !== "object") return labels;
|
||||
const propRecord = properties as Record<string, unknown>;
|
||||
for (const key in propRecord) {
|
||||
if (isArrayTypedSchema(propRecord[key])) labels.add(key);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble typed yield calls into the final payload consumed by schema validation.
|
||||
*
|
||||
* A non-empty array `type` contributes an incremental section and never decides
|
||||
* termination by itself. A string `type` with omitted `data` makes the last
|
||||
* assistant turn the raw terminal result. Other string-typed yields contribute
|
||||
* the terminal labelled section. Untyped terminal yields keep the historical
|
||||
* "last yield wins" behavior unless no terminal yield exists, in which case
|
||||
* accumulated typed sections finalize on idle.
|
||||
*/
|
||||
export function assembleYieldResult(
|
||||
yieldItems: YieldItem[],
|
||||
lastAssistantText?: string,
|
||||
arrayLabels?: ReadonlySet<string>,
|
||||
): AssembledYieldResult | undefined {
|
||||
if (yieldItems.length === 0) return undefined;
|
||||
let terminalItem: YieldItem | undefined;
|
||||
for (let index = yieldItems.length - 1; index >= 0; index--) {
|
||||
const item = yieldItems[index];
|
||||
if (!item) continue;
|
||||
if (!isIncrementalYieldType(item.type)) {
|
||||
terminalItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let hasTypedSections = false;
|
||||
for (const item of yieldItems) {
|
||||
if (getYieldLabels(item.type).length > 0) {
|
||||
hasTypedSections = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (terminalItem && typeof terminalItem.type === "string" && terminalItem.data === undefined) {
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, getYieldLabels(terminalItem.type));
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
if (!hasTypedSections && terminalItem) {
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
|
||||
const sections: Record<string, unknown> = {};
|
||||
const sectionCounts = new Map<string, number>();
|
||||
let schemaOverridden = false;
|
||||
let missingData = false;
|
||||
let hasSections = false;
|
||||
|
||||
for (const item of yieldItems) {
|
||||
if (item.status === "aborted") continue;
|
||||
schemaOverridden ||= item.schemaOverridden === true;
|
||||
const labels = getYieldLabels(item.type);
|
||||
if (labels.length === 0) continue;
|
||||
const resolved = resolveYieldPayload(item, lastAssistantText, labels);
|
||||
missingData ||= resolved.missingData;
|
||||
const incremental = isIncrementalYieldType(item.type);
|
||||
for (const label of labels) {
|
||||
appendYieldSection(
|
||||
sections,
|
||||
sectionCounts,
|
||||
label,
|
||||
resolved.value,
|
||||
incremental && (arrayLabels?.has(label) ?? false),
|
||||
);
|
||||
hasSections = true;
|
||||
}
|
||||
if (!isIncrementalYieldType(item.type)) break;
|
||||
}
|
||||
|
||||
if (hasSections) {
|
||||
return { data: sections, schemaOverridden, rawText: false, missingData };
|
||||
}
|
||||
|
||||
if (!terminalItem) return undefined;
|
||||
const resolved = resolveYieldPayload(terminalItem, lastAssistantText, []);
|
||||
return {
|
||||
data: resolved.value,
|
||||
schemaOverridden: terminalItem.schemaOverridden === true,
|
||||
rawText: resolved.fromLastAssistantText && typeof resolved.value === "string",
|
||||
missingData: resolved.missingData,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user