diff --git a/packages/ai/test/baseten-models.test.ts b/packages/ai/test/baseten-models.test.ts index 698ec382e..403779dbb 100644 --- a/packages/ai/test/baseten-models.test.ts +++ b/packages/ai/test/baseten-models.test.ts @@ -1,8 +1,22 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getModel, streamSimple } from "../src/compat.ts"; +import { streamSimple } from "../src/compat.ts"; import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; + +const catalog = generateModelsFixture({ + baseten: modelsDevProvider({ + "zai-org/GLM-5.2": { + limit: { context: 1048576, output: 262144 }, + cost: { input: 1.4, output: 4.4, cache_read: 0.3 }, + }, + "moonshotai/Kimi-K2.6": { + reasoning_options: [{ type: "toggle" }], + }, + }), +}); +const getModel = (provider: string, id: string) => catalog.getModel(provider, id, "openai-completions"); const originalBasetenApiKey = process.env.BASETEN_API_KEY; afterEach(() => { @@ -14,7 +28,7 @@ afterEach(() => { }); describe("Baseten models", () => { - it("registers GLM 5.2 as the default OpenAI-compatible reasoning model", () => { + it("generates GLM 5.2 OpenAI-compatible reasoning metadata from upstream values", () => { const model = getModel("baseten", "zai-org/GLM-5.2"); expect(model).toMatchObject({ diff --git a/packages/ai/test/generate-models-fixture.ts b/packages/ai/test/generate-models-fixture.ts new file mode 100644 index 000000000..5c26f6741 --- /dev/null +++ b/packages/ai/test/generate-models-fixture.ts @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { Api, Model } from "../src/types.ts"; + +/** Small upstream inputs, not snapshots of generated output or the live catalog. */ +export function modelsDevProvider(models: Record): { models: Record } { + return { + models: Object.fromEntries( + Object.entries(models).map(([id, metadata]) => [ + id, + { id, name: id, tool_call: true, reasoning: true, ...metadata }, + ]), + ), + }; +} + +/** Exercise the production generator without network access or source-tree writes. */ +export function generateModelsFixture(modelsDev: Record) { + const root = mkdtempSync(join(tmpdir(), "diffex-model-catalog-fixture-")); + try { + // Strict generation requires this explicit Individual plan allowlist. + const catalog = { + "alibaba-token-plan": modelsDevProvider( + Object.fromEntries( + [ + "deepseek-v4-flash-0731", + "deepseek-v4-pro", + "glm-5.2", + "qwen3.6-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max", + ].map((id) => [id, {}]), + ), + ), + ...modelsDev, + }; + const preloadPath = join(root, "mock-catalog.mjs"); + writeFileSync( + preloadPath, + `const catalog = ${JSON.stringify(catalog)};\n` + + `globalThis.fetch = async (input) => {\n` + + ` const url = String(input);\n` + + ` if (url === "https://models.dev/api.json") return Response.json(catalog);\n` + + ` if (["https://openrouter.ai/api/v1/models", "https://ai-gateway.vercel.sh/v1/models"].includes(url)) return Response.json({ data: [] });\n` + + ` throw new Error(\`Unexpected fetch: \${url}\`);\n` + + `};\n`, + ); + const result = spawnSync( + process.execPath, + [ + "--import", + pathToFileURL(preloadPath).href, + "scripts/generate-models.ts", + "--strict", + "--json-only", + "--json-output", + join(root, "catalog"), + ], + { cwd: fileURLToPath(new URL("..", import.meta.url)), encoding: "utf8", timeout: 10_000 }, + ); + if (result.status !== 0) { + throw new Error(`Fixture model generation failed: ${result.stdout}\n${result.stderr}`, { + cause: result.error, + }); + } + const providers = JSON.parse(readFileSync(join(root, "catalog/models.json"), "utf8")) as Record< + string, + Record> + >; + return { + getModels(provider: string): Model[] { + return Object.values(providers[provider] ?? {}); + }, + getModel(provider: string, id: string, api: TApi): Model { + const model = providers[provider]?.[id]; + if (!model || model.api !== api) throw new Error(`Missing fixture model ${provider}/${id} (${api})`); + return structuredClone(model) as Model; + }, + }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index 32e2d0af5..bff63b61e 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it, vi } from "vitest"; import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts"; -import { getModel } from "../src/compat.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; import type { Context } from "../src/types.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; + +const catalog = generateModelsFixture({ + "github-copilot": modelsDevProvider({ + "claude-opus-4.7": {}, + "claude-opus-5": {}, + "claude-sonnet-4.6": {}, + }), +}); +const getModel = (provider: string, id: string) => catalog.getModel(provider, id, "anthropic-messages"); const mockState = vi.hoisted(() => ({ constructorOpts: undefined as Record | undefined, diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 4ac18e037..408f28c4a 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -1,9 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Type } from "typebox"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/compat.ts"; -import { complete, getModel } from "../src/compat.ts"; +import { complete, getModel, getModels } from "../src/compat.ts"; import type { StreamOptions } from "../src/types.ts"; type StreamOptionsWithExtras = StreamOptions & Record; @@ -311,8 +311,15 @@ describe("Tool Results with Images", () => { }); }); - describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (Kimi-K2.6)", () => { - const llm = getModel("baseten", "moonshotai/Kimi-K2.6"); + describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider (vision-capable reasoning model)", () => { + let llm: Model; + beforeAll(() => { + const model = getModels("baseten").find( + (candidate) => candidate.reasoning && candidate.input.includes("image"), + ); + if (!model) throw new Error("Baseten catalog has no vision-capable reasoning model"); + llm = model; + }); const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras; it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c266bd31a..7f4b2d36b 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1608,7 +1608,14 @@ describe("openai-completions tool_choice", () => { }); it("uses OpenRouter reasoning object instead of reasoning_effort", async () => { - const model = getModel("openrouter", "deepseek/deepseek-r1")!; + const model: Model<"openai-completions"> = { + ...localOpenAICompletionsModel, + id: "test/reasoning-model", + name: "OpenRouter reasoning fixture", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: { thinkingFormat: "openrouter" }, + }; let payload: unknown; await streamSimple( diff --git a/packages/ai/test/openai-responses-compat.test.ts b/packages/ai/test/openai-responses-compat.test.ts index cabab8258..46f7c342a 100644 --- a/packages/ai/test/openai-responses-compat.test.ts +++ b/packages/ai/test/openai-responses-compat.test.ts @@ -72,7 +72,19 @@ describe("openai-responses provider defaults", () => { }); it("omits reasoning when no reasoning is requested", async () => { - const model = getModel("github-copilot", "gpt-5-mini"); + const model: Model<"openai-responses"> = { + id: "gpt-5-mini", + name: "Copilot reasoning fixture", + api: "openai-responses", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + reasoning: true, + thinkingLevelMap: { off: null, minimal: "low" }, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }; let capturedPayload: unknown; vi.spyOn(globalThis, "fetch").mockResolvedValue( diff --git a/packages/ai/test/openai-responses-tool-result-images.test.ts b/packages/ai/test/openai-responses-tool-result-images.test.ts index f881118b6..4408a21ba 100644 --- a/packages/ai/test/openai-responses-tool-result-images.test.ts +++ b/packages/ai/test/openai-responses-tool-result-images.test.ts @@ -3,9 +3,9 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ResponseFunctionCallOutputItemList } from "openai/resources/responses/responses.js"; import { Type } from "typebox"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import type { Api, Context, Model, StreamOptions, Tool, ToolResultMessage } from "../src/compat.ts"; -import { complete, getModel } from "../src/compat.ts"; +import { complete, getModel, getModels } from "../src/compat.ts"; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts"; import { resolveApiKey } from "./oauth.ts"; @@ -162,8 +162,15 @@ describe("Responses API tool result images", () => { }); }); - describe("GitHub Copilot Responses Provider (gpt-5-mini)", () => { - const model = getModel("github-copilot", "gpt-5-mini"); + describe("GitHub Copilot Responses Provider (vision-capable reasoning model)", () => { + let model: Model; + beforeAll(() => { + const candidate = getModels("github-copilot").find( + (value) => value.api === "openai-responses" && value.reasoning && value.input.includes("image"), + ); + if (!candidate) throw new Error("Copilot catalog has no vision-capable Responses reasoning model"); + model = candidate; + }); it.skipIf(!githubCopilotToken)( "should send tool result images in function_call_output", diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index 2d1644bf7..b0680b258 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -21,6 +21,7 @@ import type { ProviderStreams, } from "../src/types.ts"; import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; function fakeAuthContext(env: Record, files: string[] = []): AuthContext { return { @@ -66,27 +67,44 @@ describe("builtin providers", () => { expect(getBuiltinModel("anthropic", "claude-haiku-4-5").compat?.supportsStrictTools).toBe(true); }); - it("uses official Kimi K3 pricing for Moonshot providers", () => { - const models = builtinModels(); + it.each([ + { input: 3, output: 15, cache_read: 0.3, cache_write: 3 }, + { input: 2.9879, output: 14.9395, cache_read: 0.2988, cache_write: 2.9879 }, + ])("preserves upstream Kimi K3 pricing for Moonshot providers: %j", (cost) => { + const models = generateModelsFixture( + Object.fromEntries( + ["moonshotai", "moonshotai-cn"].map((provider) => [ + provider, + modelsDevProvider({ + "kimi-k3": { cost }, + }), + ]), + ), + ); for (const provider of ["moonshotai", "moonshotai-cn"]) { - expect(models.getModel(provider, "kimi-k3")?.cost).toEqual({ - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3, + expect(models.getModel(provider, "kimi-k3", "openai-completions").cost).toEqual({ + input: cost.input, + output: cost.output, + cacheRead: cost.cache_read, + cacheWrite: cost.cache_write, }); } }); it("uses API-equivalent implied pricing for Kimi Coding subscription models", () => { - const models = builtinModels(); + const models = generateModelsFixture({ + "kimi-code-plan-cn": modelsDevProvider({ + k3: { cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 } }, + "kimi-for-coding-highspeed": {}, + }), + }); const expectedCosts = { k3: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, "kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, }; for (const [modelId, cost] of Object.entries(expectedCosts)) { - expect(models.getModel("kimi-coding", modelId)?.cost).toEqual(cost); + expect(models.getModel("kimi-coding", modelId, "anthropic-messages").cost).toEqual(cost); } }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index cea914d26..8e7355888 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1392,13 +1392,11 @@ describe("Generate E2E Tests", () => { }); it.skipIf(!githubCopilotToken)("should handle thinking", { retry: 2 }, async () => { - const thinkingModel = getModel("github-copilot", "gpt-5-mini"); - await handleThinking(thinkingModel, { apiKey: githubCopilotToken, reasoningEffort: "high" }); + await handleThinking(llm, { apiKey: githubCopilotToken, reasoningEffort: "high" }); }); it.skipIf(!githubCopilotToken)("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - const thinkingModel = getModel("github-copilot", "gpt-5-mini"); - await multiTurn(thinkingModel, { apiKey: githubCopilotToken, reasoningEffort: "high" }); + await multiTurn(llm, { apiKey: githubCopilotToken, reasoningEffort: "high" }); }); it.skipIf(!githubCopilotToken)("should handle image input", { retry: 3 }, async () => { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index f6db09a8a..011e80f0d 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { getModel, getSupportedThinkingLevels } from "../src/compat.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; describe("getSupportedThinkingLevels", () => { it("includes max but not xhigh for Anthropic Opus 4.6 on anthropic-messages API", () => { @@ -115,10 +116,21 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]); }); - it("includes only low, high, max for Kimi Coding K3", () => { - const model = getModel("kimi-coding", "k3"); - expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]); + it.each([ + [ + ["low", "high", "max"], + ["low", "high", "max"], + ], + [ + ["none", "low", "high", "max"], + ["off", "low", "high", "max"], + ], + ])("uses upstream Kimi Coding K3 effort options %j", (values, expected) => { + const catalog = generateModelsFixture({ + "kimi-code-plan-cn": modelsDevProvider({ k3: { reasoning_options: [{ type: "effort", values }] } }), + }); + const model = catalog.getModel("kimi-coding", "k3", "anthropic-messages"); + expect(getSupportedThinkingLevels(model)).toEqual(expected); }); it("includes only high for OpenCode Grok Build", () => { diff --git a/packages/ai/test/together-models.test.ts b/packages/ai/test/together-models.test.ts index a3c3df919..bc877b963 100644 --- a/packages/ai/test/together-models.test.ts +++ b/packages/ai/test/together-models.test.ts @@ -1,7 +1,23 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getModel } from "../src/compat.ts"; import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; + +const catalog = generateModelsFixture({ + together: modelsDevProvider({ + "moonshotai/Kimi-K3": { + modalities: { input: ["text", "image"] }, + limit: { context: 1048576, output: 131072 }, + cost: { input: 3, output: 15, cache_read: 0.3 }, + }, + "openai/gpt-oss-120b": { + reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }], + }, + "deepseek-ai/DeepSeek-V4-Pro-0813": {}, + "MiniMaxAI/MiniMax-M2.7": {}, + }), +}); +const getModel = (provider: string, id: string) => catalog.getModel(provider, id, "openai-completions"); const originalTogetherApiKey = process.env.TOGETHER_API_KEY; afterEach(() => { @@ -13,7 +29,7 @@ afterEach(() => { }); describe("Together models", () => { - it("registers the default Kimi K3 model via OpenAI-compatible Chat Completions API", () => { + it("generates Kimi K3 metadata for the OpenAI-compatible Chat Completions API", () => { const model = getModel("together", "moonshotai/Kimi-K3"); expect(model).toBeDefined(); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 92e464203..0f14cd19b 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -1,9 +1,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; -import { XAI_MODELS } from "../src/providers/xai.models.ts"; import { xaiProvider } from "../src/providers/xai.ts"; import type { Context, Model } from "../src/types.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; + +const retiredModelIds = [ + "grok-3", + "grok-3-fast", + "grok-4.20-0309-non-reasoning", + "grok-4.20-0309-reasoning", + "grok-code-fast-1", +]; +const catalog = generateModelsFixture({ + xai: modelsDevProvider({ + ...Object.fromEntries(retiredModelIds.map((id) => [id, {}])), + "grok-4.3": {}, + "grok-4.5": { reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }] }, + }), +}); +const grok45 = catalog.getModel("xai", "grok-4.5", "openai-responses"); type CapturedRequest = { url: string; @@ -61,26 +77,33 @@ describe("xAI Responses provider", () => { }); it("excludes retired and redundant models from the built-in catalog", () => { - for (const modelId of [ - "grok-3", - "grok-3-fast", - "grok-4.20-0309-non-reasoning", - "grok-4.20-0309-reasoning", - "grok-code-fast-1", - ]) { - expect(Object.keys(XAI_MODELS)).not.toContain(modelId); + for (const modelId of retiredModelIds) { + expect(catalog.getModels("xai").map((model) => model.id)).not.toContain(modelId); } }); - it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { - expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); - expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); - expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + it("uses Responses for Grok 4.5 with upstream effort options", () => { + expect(getSupportedThinkingLevels(grok45)).toEqual(["low", "medium", "high"]); + expect(catalog.getModel("xai", "grok-4.3", "openai-completions").api).toBe("openai-completions"); + }); + + it("preserves newly advertised Grok 4.5 effort options", () => { + const updated = generateModelsFixture({ + xai: modelsDevProvider({ + "grok-4.5": { reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh"] }] }, + }), + }); + expect(getSupportedThinkingLevels(updated.getModel("xai", "grok-4.5", "openai-responses"))).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); }); it("uses /responses with bearer auth and xAI-compatible request fields", async () => { const captured = await captureRequest( - XAI_MODELS["grok-4.5"], + grok45, { systemPrompt: "You are a careful coding assistant.", messages: [{ role: "user", content: "hello", timestamp: 1 }], diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts index d39cf8718..89c9f23e8 100644 --- a/packages/ai/test/xiaomi-models.test.ts +++ b/packages/ai/test/xiaomi-models.test.ts @@ -1,15 +1,26 @@ import { describe, expect, it } from "vitest"; -import { getModel, getModels } from "../src/compat.ts"; +import { generateModelsFixture, modelsDevProvider } from "./generate-models-fixture.ts"; + +const catalog = generateModelsFixture({ + xiaomi: modelsDevProvider({ "mimo-v2-flash": {}, "mimo-v2-omni": {}, "mimo-v2.5-pro": {} }), + ...Object.fromEntries( + ["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"].map((provider) => [ + provider, + modelsDevProvider({ "mimo-v2.5-pro": {} }), + ]), + ), +}); describe("Xiaomi MiMo models", () => { it.each(["mimo-v2-flash", "mimo-v2-omni"] as const)("keeps %s on the API billing provider", (modelId) => { - expect(getModel("xiaomi", modelId)).toBeDefined(); + expect(catalog.getModel("xiaomi", modelId, "openai-completions").baseUrl).toBe("https://api.xiaomimimo.com/v1"); }); it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( "omits API-billing-only models from %s", (provider) => { - const modelIds = getModels(provider).map((model) => model.id); + const modelIds = catalog.getModels(provider).map((model) => model.id); + expect(modelIds).toContain("mimo-v2.5-pro"); expect(modelIds).not.toContain("mimo-v2-flash"); expect(modelIds).not.toContain("mimo-v2-omni"); }, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c46ab0f97..72214f7c8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Regular root sessions now reconcile selected harness memory and evolved skills at construction, idle reload, and eligible manual compaction boundaries. + ## [0.3.2] - 2026-09-29 ### New Features diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 7d6096236..a95bca849 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -235,7 +235,7 @@ Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUp ### Harness Evolution -`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at the next prompt boundary; in-flight and admitted queued requests remain pinned. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md). +`/evolve` shows evidence eligibility, jobs, memory proposals, generated skill candidates, readable supporting and contradicting evidence, draft diffs, validation, evaluation, target revisions, activation, and rollback. `/version` selects a complete immutable harness revision. Memory and evolved skills change together at regular session construction or after successful context compaction. Background updates do not change the active session at prompt boundaries or on `/reload`, preserving its input cache. A TUI notice announces pending updates; use `/clear` in the same project to apply them immediately in a fresh session. `/version` still switches explicitly. The footer shows the active harness and background activity. See [Harness Evolution](docs/evolution.md). --- diff --git a/packages/coding-agent/docs/evolution.md b/packages/coding-agent/docs/evolution.md index dcec4e9dc..44026c610 100644 --- a/packages/coding-agent/docs/evolution.md +++ b/packages/coding-agent/docs/evolution.md @@ -30,9 +30,9 @@ A proposed skill is a candidate, not a bundle member. An accepted candidate has ## Request boundaries -Harness selection is resolved at prompt admission. A fresh prompt observes the selected revision; in-flight work and already admitted queued messages keep their pinned memory and skill versions. Background candidate validation reads the atomically selected revision directly, so it does not depend on a session reaching its next prompt boundary first. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later. +Harness selection is resolved when a regular root session is constructed and after successful manual or automatic context compaction, before any continuation. Background evolution does not change the active harness at ordinary prompt boundaries or on `/reload`, so it does not invalidate the existing conversation's input cache. The TUI announces pending updates: they apply automatically when context compacts, or immediately in a fresh session through `/clear` in the same project directory. Cancelled or failed compaction leaves the active harness unchanged. `/version` remains an explicit immediate switch. Fixed and explicitly pinned subagent sessions do not follow workspace selection automatically. Background candidate validation reads the atomically selected revision directly, independently of the active session's pinned revision. If `/version` changes while an evolution job is running, its memory activation continues from the frozen base and cannot replace the newer selection. Separate skill work can still activate a compatible descendant later. -The footer identifies the active harness and shows background evolution activity. `/skills` lists only evolved skills from the active revision whose workspace identity matches the current workspace. Harness selection is workspace-scoped: sessions in the same workspace observe each other's selections at prompt boundaries, while sessions in other workspaces keep their own selected revision. +The footer identifies the active harness and shows background evolution activity. `/skills` lists only evolved skills from the active revision whose workspace identity matches the current workspace. Harness selection is workspace-scoped: sessions in the same workspace adopt each other's selections after compaction or when starting a new session, while sessions in other workspaces keep their own selected revision. ## Automatic eligibility @@ -53,7 +53,7 @@ Scores start at 0, persist per workspace, and apply atomically once per session/ Before an eligible automatic or manual run freezes its input, Diffex grooms the selected revision's lessons, skipping grooming when its memory is empty. Lessons with scores at or below -5 are removed first; if more than 12 remain, the lowest-scoring lessons are removed until 12 remain. Equal scores remove earlier lessons first. This maintenance includes manual lessons, verifies their content digests, and compacts lesson numbering through the memory writer. Below these limits, the selected revision is unchanged. -The job snapshots the post-groom revision, including its evolved skills, rather than the session's pinned revision. In-flight prompts remain pinned; the next prompt observes the selected revision. The `/evolve` Browse view shows **Reorganizing lessons** during grooming. +The job snapshots the post-groom revision, including its evolved skills, rather than the session's pinned revision. Active sessions remain pinned until successful compaction or a new session. The `/evolve` Browse view shows **Reorganizing lessons** during grooming. ## Private skill feedback @@ -63,7 +63,7 @@ Skill reports never enter conversation history, extension events, exports, compa Only output-bound package identities still present in the selected workspace revision accrue points. Updated packages start at 0, retired packages stop accruing, and installed skills are never scored. Scores never modify skill artifacts. `DEFAULT_SKILL_POLICY` and `SkillPolicy` in `packages/evolution/src/skill-score-store.ts` expose the shared typed scoring, grooming, and reset defaults, not per-session settings. Reporting and grooming are skipped without workspace-matching evolved skills in the active harness. -Before an eligible automatic or manual run freezes its input, skill grooming removes workspace-matching evolved skills at or below -8 points, then removes the lowest-scoring survivors until at most 10 remain. Equal scores are ordered by skill ID. Removals verify the frozen package version and content digest and use the skill writer's staging and activation path without candidate evaluation. Installed skills and other workspaces are untouched; historical revisions, scores, and lifecycle observations remain available for audit. Below these limits grooming does not advance the selection. The job freezes the post-groom revision and skill catalog; the next prompt observes that selection while admitted prompts remain pinned. +Before an eligible automatic or manual run freezes its input, skill grooming removes workspace-matching evolved skills at or below -8 points, then removes the lowest-scoring survivors until at most 10 remain. Equal scores are ordered by skill ID. Removals verify the frozen package version and content digest and use the skill writer's staging and activation path without candidate evaluation. Installed skills and other workspaces are untouched; historical revisions, scores, and lifecycle observations remain available for audit. Below these limits grooming does not advance the selection. The job freezes the post-groom revision and skill catalog; active sessions adopt that selection only after successful compaction or in a new session. ## Validation and security diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index 1794ec26a..031b8e442 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -74,7 +74,7 @@ This is progressive disclosure: only names, descriptions, and locations are alwa ### Evolved Skills -Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Selecting another revision changes `EVOLVE.md` memory and the evolved-skill set together at the next prompt boundary; in-flight and already admitted queued requests remain pinned. +Evolved skills are immutable, workspace-scoped packages in a harness revision. The package version covers `SKILL.md` and every bundled UTF-8 supporting file, while the content digest identifies `SKILL.md` itself. Diffex exposes a package only when the active revision and workspace identity match. Background selections change `EVOLVE.md` memory and the evolved-skill set together at regular root-session construction or after successful manual or automatic context compaction. Ordinary prompt boundaries and `/reload` keep the current harness pinned to preserve input caching. Use `/clear` in the same project to apply the selected revision in a fresh session, or `/version` to switch explicitly. An evolved reference binds its skill ID, content version, digest, workspace, and harness revision. A reference from an inactive revision is rejected. `/reload` refreshes installed skills while preserving the evolved overlay from the active harness. Accepted evolved skills activate automatically after validation and any configured evaluation finish. Use `/evolve` to inspect provenance, validation, available evaluation results, activation, and rollback. See [Harness Evolution](evolution.md). diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 01aea6ccc..2709698e4 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -239,7 +239,7 @@ export async function createInternalAgentSessionFromServices( internalCustomTools: readonly ToolDefinition[] = [], ): Promise { registerSubagentRuntimeDiagnosticSink(options.sessionManager, options.services.diagnostics); - return createInternalAgentSession( + const result = await createInternalAgentSession( { backgroundProcessPolicy: options.backgroundProcessPolicy, cwd: options.services.cwd, @@ -265,4 +265,8 @@ export async function createInternalAgentSessionFromServices( }, internalCustomTools, ); + if (result.harnessStateWarning) { + options.services.diagnostics.push({ type: "warning", message: result.harnessStateWarning }); + } + return result; } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9cb758052..236d1aa79 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -342,7 +342,7 @@ export interface AgentSessionConfig { harnessVersionBundle?: HarnessVersionBundle; /** Stable identity used to scope evolved harness skills. */ workspaceIdentity?: HarnessWorkspaceIdentity; - /** Keep the supplied harness revision fixed instead of following the global selected pointer. */ + /** Keep the supplied harness revision fixed instead of refreshing it after compaction. */ pinHarnessVersion?: boolean; /** Parent context for passive runtime telemetry. */ telemetryContext?: TelemetryContext; @@ -1475,7 +1475,7 @@ export class AgentSession { await this._notifyHarnessActivated(activatedBundle.manifest.version); } - private async _refreshSelectedHarnessAtPromptBoundary(): Promise { + private async _reconcileSelectedHarness(): Promise { if (!this._harnessStateStore || this._pinHarnessVersion || hasFixedHarnessSubagentSession(this.sessionManager)) { return; } @@ -1486,6 +1486,24 @@ export class AgentSession { await this._activateHarnessBundle(selected); } + private async _refreshHarnessAfterCompaction(): Promise { + try { + const systemPromptOverride = this._systemPromptOverride; + await this._reconcileSelectedHarness(); + // A before_agent_start override still owns this logical run's effective prompt. + if (systemPromptOverride !== undefined) { + this._systemPromptOverride = systemPromptOverride; + this._installCollaborationModeState(this._deriveCollaborationModeState(this._collaborationMode)); + } + if (this._activeRunCollaborationState) { + this._activeRunCollaborationState = this._capturePlanAdmissionState(); + } + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. @@ -2311,10 +2329,20 @@ export class AgentSession { let consumedNextTurnMessages: CustomMessage[] | undefined; let preflightAccepted = false; let pendingCompaction: CompactionAppendTransaction | undefined; - const commitPendingCompaction = (): void => { + const commitPendingCompaction = async (): Promise => { if (!pendingCompaction) return; pendingCompaction.commit(); pendingCompaction = undefined; + // Do not refresh a harness for compaction that cancellable preflight can still roll back. + const refreshError = await this._refreshHarnessAfterCompaction(); + if (refreshError) { + this._extensionRunner.emitError({ + extensionPath: "", + event: "harness_refresh", + error: refreshError, + }); + } + if (collaborationState) collaborationState = this._capturePlanAdmissionState(); }; const rollbackPendingCompaction = (): void => { if (!pendingCompaction) return; @@ -2344,7 +2372,6 @@ export class AgentSession { ); } - if (!this.isStreaming) await this._refreshSelectedHarnessAtPromptBoundary(); signal?.throwIfAborted(); // Emit input event for extension interception (before skill/template expansion) @@ -2529,7 +2556,7 @@ export class AgentSession { if (signal?.aborted) { rollbackPendingCompaction(); } else { - commitPendingCompaction(); + await commitPendingCompaction(); } if (signal?.aborted && consumedNextTurnMessages) { this._pendingNextTurnMessages = [...consumedNextTurnMessages, ...this._pendingNextTurnMessages]; @@ -2550,7 +2577,7 @@ export class AgentSession { this._pendingNextTurnMessages = [...(consumedNextTurnMessages ?? []), ...this._pendingNextTurnMessages]; throw signal.reason; } - commitPendingCompaction(); + await commitPendingCompaction(); throw error; } if (signal?.aborted) { @@ -2559,7 +2586,11 @@ export class AgentSession { consumedNextTurnMessages = undefined; throw signal.reason; } - commitPendingCompaction(); + await commitPendingCompaction(); + if (signal?.aborted) { + this._pendingNextTurnMessages = [...(consumedNextTurnMessages ?? []), ...this._pendingNextTurnMessages]; + throw signal.reason; + } consumedNextTurnMessages = undefined; releaseUnsignaledPreflight(); this._releaseIdleSubagentEscalationsForSelectedBranch(); @@ -3381,6 +3412,7 @@ export class AgentSession { usage, willRetry: false, }); + const refreshError = await this._refreshHarnessAfterCompaction(); // compaction_end listeners may submit queued prompts, so expose idle state before notifying them. this._compactionAbortController = undefined; this._emit({ @@ -3389,6 +3421,7 @@ export class AgentSession { result: compactionResult, aborted: false, willRetry: false, + errorMessage: refreshError, }); return compactionResult; } catch (error) { @@ -3745,7 +3778,8 @@ export class AgentSession { signal, deferCommit, }); - this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + const refreshError = deferCommit ? undefined : await this._refreshHarnessAfterCompaction(); + this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry, errorMessage: refreshError }); if (willRetry) { const messages = this.agent.state.messages; @@ -4175,6 +4209,18 @@ export class AgentSession { } async reload(options?: { beforeSessionStart?: () => void | Promise }): Promise { + if ( + this._promptAdmissionInProgress || + this._harnessSwitchInProgress || + this.isStreaming || + this.agent.hasQueuedMessages() || + this.isRetrying || + this._manualCompactionOperations > 0 || + this.isCompacting || + this.isBashRunning + ) { + throw new Error("Cannot reload while the session is busy."); + } await this._subagentController?.reset(); const oldRunner = this._extensionRunner; const previousFlagValues = oldRunner.getFlagValues(); diff --git a/packages/coding-agent/src/core/harness-state-runtime.ts b/packages/coding-agent/src/core/harness-state-runtime.ts index 311ef6d26..461dfca2b 100644 --- a/packages/coding-agent/src/core/harness-state-runtime.ts +++ b/packages/coding-agent/src/core/harness-state-runtime.ts @@ -42,6 +42,23 @@ export interface LoadedHarnessState { warning: string | undefined; } +/** + * Load the selected harness from an existing store. This preserves the + * startup fallback used when a late selection read cannot be completed. + */ +export async function loadSelectedHarnessState(store: HarnessStateStore): Promise { + try { + return { store, bundle: await store.loadCurrent(), warning: undefined }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + store, + bundle: undefined, + warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`, + }; + } +} + /** * Initialize the shared harness revision store and load this workspace's selected revision. * Invalid selected state is deliberately left untouched so `/version` can repair it. @@ -53,7 +70,6 @@ export async function loadWorkspaceHarnessState( const store = new HarnessStateStore(agentDir, { workspace }); try { await store.initialize(); - return { store, bundle: await store.loadCurrent(), warning: undefined }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { @@ -62,4 +78,5 @@ export async function loadWorkspaceHarnessState( warning: `Harness state could not be activated: ${message}. Continuing with the built-in harness.`, }; } + return loadSelectedHarnessState(store); } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index ceea86385..b3a35fc55 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -20,6 +20,7 @@ import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; import { + loadSelectedHarnessState, loadWorkspaceHarnessState, resolveEvolutionWorkspaceStateDirectory, resolveHarnessWorkspace, @@ -51,6 +52,7 @@ import { resolveRestoredSubagentConfig, } from "./subagents/services.ts"; import { RootLocalEphemeralSubagentSessionManagerFactory } from "./subagents/session-manager-factory.ts"; +import { hasFixedHarnessSubagentSession } from "./subagents/session-runtime.ts"; import type { SubagentEscalationCallback, SubagentSupervisionOptions } from "./subagents/types.ts"; import { time } from "./timings.ts"; import { @@ -440,9 +442,6 @@ export async function createInternalAgentSession( const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); const workspace = await resolveHarnessWorkspace(cwd); const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); - const loadedHarnessState = options.harnessStateStore - ? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined } - : await loadWorkspaceHarnessState(agentDir, workspace.workspace); const subagentOptions = normalizeSubagentOptions(options.subagents); const telemetryContext = options.telemetryContext ?? NOOP_TELEMETRY_CONTEXT; assertNoReservedSubagentTools(options.customTools); @@ -454,6 +453,7 @@ export async function createInternalAgentSession( const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + const usesFixedHarness = options.pinHarnessVersion === true || hasFixedHarnessSubagentSession(sessionManager); const subagentController = subagentOptions === false ? undefined @@ -667,6 +667,11 @@ export async function createInternalAgentSession( sessionManager.appendThinkingLevelChange(thinkingLevel); } + const loadedHarnessState = options.harnessStateStore + ? usesFixedHarness + ? { store: options.harnessStateStore, bundle: options.harnessVersionBundle, warning: undefined } + : await loadSelectedHarnessState(options.harnessStateStore) + : await loadWorkspaceHarnessState(agentDir, workspace.workspace); const session = new AgentSession({ backgroundProcessPolicy: options.backgroundProcessPolicy, agent, diff --git a/packages/coding-agent/src/modes/interactive/components/evolution-footer-status.ts b/packages/coding-agent/src/modes/interactive/components/evolution-footer-status.ts index 85fcf0c0e..de4d87cdd 100644 --- a/packages/coding-agent/src/modes/interactive/components/evolution-footer-status.ts +++ b/packages/coding-agent/src/modes/interactive/components/evolution-footer-status.ts @@ -45,7 +45,6 @@ export class EvolutionFooterStatus { #spinnerFrameIndex = 0; #selectedHarnessVersion: string | null = null; #selectedHarnessVersionLoaded = false; - #activateSelectedHarness = false; #versionInfoGeneration = 0; #loadedVersionInfoGeneration = -1; #refreshPromise: Promise | null = null; @@ -138,14 +137,11 @@ export class EvolutionFooterStatus { if (selectionChanged) { this.#versionInfoGeneration += 1; this.#refreshAgain = true; - } - if ((selectionChanged || this.#activateSelectedHarness) && selectedVersion !== null) { - this.#activateSelectedHarness = false; await this.#onSelectedHarnessChange?.(selectedVersion); } } } catch { - // Footer status is best-effort. Prompt-boundary harness refresh remains authoritative. + // Footer status is best-effort. Session lifecycle boundaries control harness adoption. } finally { if (!this.#disposed) { this.#requestRender(); @@ -167,7 +163,6 @@ export class EvolutionFooterStatus { } if (event.type === "completed" || event.type === "failed") this.#setRunning(false); if (event.type === "completed") { - this.#activateSelectedHarness = true; this.#versionInfoGeneration += 1; } this.#requestRender(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8eb986eaa..a8c3ac8b5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -716,13 +716,11 @@ export class InteractiveMode { this.footer = new FooterComponent(this.session, this.footerDataProvider, { evolution: options.evolution, requestRender: () => this.ui.requestRender(), - onSelectedHarnessChange: async (version) => { + onSelectedHarnessChange: (version) => { if (version === this.session.activeHarnessVersion) return; - try { - await this.session.switchHarnessVersion(version); - } catch { - // A busy session refreshes the selected harness at its next prompt boundary. - } + this.showStatus( + "Evolution selected a new harness revision. Changes apply automatically after context compaction, or use /clear to apply them now.", + ); }, }); this.footerContainer = new Container(); diff --git a/packages/coding-agent/test/evolution-footer-status.test.ts b/packages/coding-agent/test/evolution-footer-status.test.ts index a7b4ea6db..37b633ab3 100644 --- a/packages/coding-agent/test/evolution-footer-status.test.ts +++ b/packages/coding-agent/test/evolution-footer-status.test.ts @@ -218,17 +218,14 @@ describe("EvolutionFooterStatus", () => { status.dispose(); }); - it("activates a newly selected evolved harness after completion", async () => { - let activeVersion: HarnessVersion = rootVersion; + it("notifies once about a newly selected evolved harness without changing the active version", async () => { const fixture = client(overview(rootVersion), [ { version: rootVersion, kind: "default", evolvedAt: null, evolvedIn: null }, ]); - const selected = vi.fn(async (version: string) => { - activeVersion = version as HarnessVersion; - }); + const selected = vi.fn(); const status = new EvolutionFooterStatus({ client: fixture.client, - getActiveHarnessVersion: () => activeVersion, + getActiveHarnessVersion: () => rootVersion, requestRender: () => {}, onSelectedHarnessChange: selected, }); @@ -252,7 +249,42 @@ describe("EvolutionFooterStatus", () => { }); await vi.waitFor(() => expect(selected).toHaveBeenCalledWith(evolvedVersion)); - expect(status.getSnapshot().activeVersionInfo?.version).toBe(evolvedVersion); + await status.refresh(); + fixture.emit({ + jobId: "job-active", + emittedAt: "2026-08-20T12:48:00.000Z", + type: "completed", + stage: "completed", + }); + await status.refresh(); + + expect(selected).toHaveBeenCalledExactlyOnceWith(evolvedVersion); + expect(status.getSnapshot().activeVersionInfo?.version).toBe(rootVersion); + status.dispose(); + }); + + it("does not notify for initial hydration or completion without a selected-version change", async () => { + const fixture = client(overview(evolvedVersion), []); + const selected = vi.fn(); + const status = new EvolutionFooterStatus({ + client: fixture.client, + getActiveHarnessVersion: () => rootVersion, + requestRender: () => {}, + onSelectedHarnessChange: selected, + }); + await status.refresh(); + expect(selected).not.toHaveBeenCalled(); + + fixture.emit({ + jobId: "job-no-change", + emittedAt: "2026-08-20T12:48:00.000Z", + type: "completed", + stage: "completed", + }); + await status.refresh(); + await status.refresh(); + + expect(selected).not.toHaveBeenCalled(); status.dispose(); }); diff --git a/packages/coding-agent/test/evolution-runtime.test.ts b/packages/coding-agent/test/evolution-runtime.test.ts index ffc9fb141..5c093af12 100644 --- a/packages/coding-agent/test/evolution-runtime.test.ts +++ b/packages/coding-agent/test/evolution-runtime.test.ts @@ -1659,7 +1659,11 @@ describe("coding-agent evolution runtime", () => { writeFileSync(join(traceDirectory, `${traceId}.jsonl`), records.map(encodeTraceRecord).join("")); const store = new HarnessStateStore(agentDir); const root = await store.initialize(); - const harness = await createHarness({ harnessStateStore: store, harnessVersionBundle: root }); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + settings: { compaction: { enabled: false, keepRecentTokens: 1 } }, + }); harnesses.push(harness); harness.setResponses([ fauxAssistantMessage( @@ -1790,6 +1794,27 @@ describe("coding-agent evolution runtime", () => { nextPromptWillRefresh: true, }); + let pinnedPrompt = ""; + harness.appendResponses([ + (context) => { + pinnedPrompt = context.systemPrompt ?? ""; + return fauxAssistantMessage("still using the original harness"); + }, + ]); + await harness.session.prompt("Continue before compaction."); + + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + expect(pinnedPrompt).not.toContain("focused-verification"); + expect(harness.session.createSkillReferences()).toEqual([]); + + harness.appendResponses([fauxAssistantMessage("Summarized the original harness conversation.")]); + const compaction = await harness.session.compact(); + expect(compaction.summary).toContain("Summarized the original harness conversation."); + expect(harness.sessionManager.getBranch()).toContainEqual( + expect.objectContaining({ type: "compaction", summary: compaction.summary }), + ); + expect(harness.session.activeHarnessVersion).toBe(activatedHarnessVersion); + let exposedPrompt = ""; harness.appendResponses([ (context) => { @@ -1812,6 +1837,8 @@ describe("coding-agent evolution runtime", () => { }), ]); + expect(harness.getPendingResponseCount()).toBe(0); + await expect(runtime.control.getSkillCandidate(started.job.jobId, candidateId)).resolves.toMatchObject({ targetHarnessVersion: activatedHarnessVersion, rollbackTargetHarnessVersion: root.manifest.version, @@ -1823,6 +1850,11 @@ describe("coding-agent evolution runtime", () => { toHarnessVersion: root.manifest.version, }); expect((await store.loadCurrent()).manifest.version).toBe(root.manifest.version); + const activeReferences = harness.session.createSkillReferences(); + await harness.session.reload(); + expect(harness.session.activeHarnessVersion).toBe(activatedHarnessVersion); + expect(harness.session.systemPrompt).toContain("focused-verification"); + expect(harness.session.createSkillReferences()).toEqual(activeReferences); await expect(runtime.control.getSkillCandidate(started.job.jobId, candidateId)).resolves.toMatchObject({ summary: { state: "accepted" }, targetHarnessVersion: null, diff --git a/packages/coding-agent/test/interactive-mode-evolution.test.ts b/packages/coding-agent/test/interactive-mode-evolution.test.ts index a11d30e55..0961dddc1 100644 --- a/packages/coding-agent/test/interactive-mode-evolution.test.ts +++ b/packages/coding-agent/test/interactive-mode-evolution.test.ts @@ -1,6 +1,10 @@ +import type { HarnessVersion } from "@diffexai/diffex-harness-state"; +import type { Container } from "@diffexai/diffex-tui"; import { setKeybindings } from "@diffexai/diffex-tui"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTOMATIC_EVOLUTION_TOKEN_THRESHOLD, type EvolutionOverview } from "#evolution"; +import type { AgentSessionRuntime } from "../src/core/agent-session-runtime.ts"; +import type { EvolutionRuntimeEvent } from "../src/core/evolution-runtime.ts"; import { KeybindingsManager } from "../src/core/keybindings.ts"; import type { EvolutionMenuClient, @@ -8,6 +12,8 @@ import type { } from "../src/modes/interactive/components/evolution-menu.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; import { initTheme } from "../src/modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../src/utils/ansi.ts"; +import { createHarness } from "./suite/harness.ts"; interface SubmitFixture { defaultEditor: { @@ -107,6 +113,65 @@ describe("InteractiveMode evolution menu", () => { expect(fixture.showEvolutionMenu).toHaveBeenCalledWith(""); }); + it.each([false, true])( + "announces a selected revision without immediate adoption (streaming: %s)", + async (streaming) => { + const harness = await createHarness(); + const rootVersion = `sha256:${"a".repeat(64)}` as HarnessVersion; + const evolvedVersion = `sha256:${"b".repeat(64)}` as HarnessVersion; + const evolution = { + ...client(), + listCurrentDirectoryHarnessVersions: vi.fn().mockResolvedValue({ + currentVersion: rootVersion, + versions: [], + diagnostics: [], + }), + }; + let listener: ((event: EvolutionRuntimeEvent) => void) | undefined; + evolution.subscribe = (next) => { + listener = next; + return () => {}; + }; + vi.mocked(evolution.overview).mockResolvedValue({ ...overview(), currentHarnessVersion: rootVersion }); + const activeVersion = harness.session.activeHarnessVersion; + const isStreaming = vi.spyOn(harness.session, "isStreaming", "get").mockReturnValue(streaming); + const switchHarnessVersion = vi.spyOn(harness.session, "switchHarnessVersion"); + const runtimeHost = { + session: harness.session, + setBeforeSessionInvalidate: () => {}, + setRebindSession: () => {}, + } as unknown as AgentSessionRuntime; + const mode = new InteractiveMode(runtimeHost, { evolution, tuiMode: "regular" }); + const state = mode as unknown as { chatContainer: Container; showStatus(message: string): void }; + const showStatus = vi.spyOn(state, "showStatus"); + try { + await vi.waitFor(() => expect(evolution.overview).toHaveBeenCalled()); + expect(showStatus).not.toHaveBeenCalled(); + vi.mocked(evolution.overview).mockResolvedValue({ ...overview(), currentHarnessVersion: evolvedVersion }); + listener?.({ + jobId: "job-evolved", + emittedAt: "2026-08-20T12:48:00.000Z", + type: "completed", + stage: "completed", + }); + await vi.waitFor(() => expect(showStatus).toHaveBeenCalledOnce()); + + const output = stripAnsi(state.chatContainer.render(200).join("\n")); + expect(output).toContain("Evolution selected a new harness revision."); + expect(output).toContain("Changes apply automatically after context compaction"); + expect(output).toContain("/clear"); + expect(switchHarnessVersion).not.toHaveBeenCalled(); + expect(harness.session.activeHarnessVersion).toBe(activeVersion); + } finally { + mode.stop(); + showStatus.mockRestore(); + isStreaming.mockRestore(); + switchHarnessVersion.mockRestore(); + await harness.cleanup(); + } + }, + ); + it("restores editor contents and closes through Escape", async () => { let component: EvolutionMenuComponent | undefined; const done = vi.fn(); diff --git a/packages/coding-agent/test/suite/agent-session-collaboration-mode.test.ts b/packages/coding-agent/test/suite/agent-session-collaboration-mode.test.ts index 3f726f038..813bff174 100644 --- a/packages/coding-agent/test/suite/agent-session-collaboration-mode.test.ts +++ b/packages/coding-agent/test/suite/agent-session-collaboration-mode.test.ts @@ -109,7 +109,8 @@ describe("AgentSession collaboration mode foundation", () => { expect(setCollaborationMode(harness.session, "plan").status).toBe("busy"); }); const prompt = harness.session.prompt("first request", { source: "extension" }); - expect(order).toEqual(["admission"]); + // Admission is synchronous and precedes input, regardless of whether input has already started. + expect(order[0]).toBe("admission"); await prompt; expect(order).toEqual(["admission", "input"]); expect(isCollaborationModeBusy(harness.session)).toBe(false); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index f197caa57..190a58a9b 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -1,12 +1,19 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage, + fauxToolCall, type Model, } from "@diffexai/diffex-ai"; +import { HarnessStateStore } from "@diffexai/diffex-harness-state"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { PLAN_MODE_SYSTEM_PROMPT, setCollaborationMode } from "../../src/core/collaboration-mode.ts"; import { estimateTokens } from "../../src/core/compaction/index.ts"; -import { createHarness, getUserTexts, type Harness } from "./harness.ts"; +import { markFixedHarnessSubagentSession } from "../../src/core/subagents/session-runtime.ts"; +import { createHarness, getMessageText, getUserTexts, type Harness, type HarnessOptions } from "./harness.ts"; type SessionWithCompactionInternals = { _checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise; @@ -87,6 +94,38 @@ function seedCompactableSession(harness: Harness): void { describe("AgentSession compaction characterization", () => { const harnesses: Harness[] = []; + const agentDirs: string[] = []; + + async function createVersionedCompactionHarness(options: HarnessOptions = {}) { + const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + agentDirs.push(agentDir); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + ...options, + harnessStateStore: store, + harnessVersionBundle: root, + settings: { + ...options.settings, + compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0, ...options.settings?.compaction }, + }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", (event) => ({ + compaction: { + summary: "versioned compaction summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + }, + })); + }, + ...(options.extensionFactories ?? []), + ], + }); + harnesses.push(harness); + return { harness, store, root }; + } afterEach(async () => { vi.useRealTimers(); @@ -94,6 +133,7 @@ describe("AgentSession compaction characterization", () => { while (harnesses.length > 0) { await harnesses.pop()?.cleanup(); } + for (const agentDir of agentDirs.splice(0)) rmSync(agentDir, { recursive: true, force: true }); }); it("manually compacts using an extension-provided summary", async () => { @@ -186,6 +226,293 @@ describe("AgentSession compaction characterization", () => { expect(harness.session.getLastAssistantText()).toBe("queued response"); }); + it("reconciles the selected harness after committed manual compaction without queued work", async () => { + const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "manual compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + try { + seedCompactableSession(harness); + const successor = await store.advance({ memory: "Use the compacted selection." }, root.manifest.version); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + + await harness.session.compact(); + + expect(harness.session.activeHarnessVersion).toBe(successor.bundle.manifest.version); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it("refreshes with a queued continuation and reports a later post-commit harness refresh failure", async () => { + const agentDir = join(tmpdir(), `diffex-compaction-harness-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(agentDir, { recursive: true }); + const store = new HarnessStateStore(agentDir); + const root = await store.initialize(); + const harness = await createHarness({ + harnessStateStore: store, + harnessVersionBundle: root, + settings: { compaction: { keepRecentTokens: 1 } }, + extensionFactories: [ + (pi) => { + pi.on("session_before_compact", async (event) => ({ + compaction: { + summary: "manual compacted", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + })); + }, + ], + }); + harnesses.push(harness); + try { + seedCompactableSession(harness); + const successor = await store.advance( + { memory: "Activate at the compaction boundary." }, + root.manifest.version, + ); + expect(successor.status).toBe("advanced"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + harness.session.agent.followUp({ + role: "custom", + customType: "test", + content: [{ type: "text", text: "queued continuation" }], + display: false, + timestamp: Date.now(), + }); + + await harness.session.compact(); + + expect(harness.session.activeHarnessVersion).toBe(successor.bundle.manifest.version); + harness.session.agent.clearAllQueues(); + seedCompactableSession(harness); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selection read failed")); + + const result = await harness.session.compact(); + + expect(result.summary).toContain("manual compacted"); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "manual", + aborted: false, + result, + errorMessage: "selection read failed", + }); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } + }); + + it.each(["default", "plan"] as const)( + "refreshes the %s harness before automatic tool and overflow continuations", + async (mode) => { + for (const reason of ["threshold", "overflow"] as const) { + const { harness, store, root } = await createVersionedCompactionHarness({ + models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }], + }); + if (mode === "plan") setCollaborationMode(harness.session, mode); + seedCompactableSession(harness); + writeFileSync(join(harness.tempDir, "context.txt"), "context to inspect"); + let selectedVersion = root.manifest.version; + const prompts: string[] = []; + harness.setResponses([ + async (context) => { + prompts.push(context.systemPrompt ?? ""); + const advanced = await store.advance( + { memory: "Use memory after automatic compaction." }, + root.manifest.version, + ); + if (advanced.status !== "advanced") throw new Error("Expected harness advance to succeed"); + selectedVersion = advanced.bundle.manifest.version; + return reason === "threshold" + ? fauxAssistantMessage(fauxToolCall("read", { path: join(harness.tempDir, "context.txt") }), { + stopReason: "toolUse", + }) + : fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" }); + }, + (context) => { + prompts.push(context.systemPrompt ?? ""); + harness.settingsManager.setCompactionEnabled(false); + return fauxAssistantMessage("continued with the new harness"); + }, + ]); + + await harness.session.prompt("x".repeat(5000)); + + expect(prompts, reason).toHaveLength(2); + expect(prompts[0]).not.toContain("Use memory after automatic compaction."); + expect(prompts[1]).toContain("Use memory after automatic compaction."); + if (mode === "plan") expect(prompts[1]).toContain(PLAN_MODE_SYSTEM_PROMPT); + expect(harness.session.activeHarnessVersion).toBe(selectedVersion); + expect(harness.eventsOfType("compaction_end")[0]).toMatchObject({ reason, aborted: false }); + } + }, + ); + + it.each([false, true])("refreshes only after pre-prompt compaction commits (cancel: %s)", async (cancel) => { + const { harness, store, root } = await createVersionedCompactionHarness({ + models: [{ id: "faux-1", contextWindow: 1000 }], + settings: { compaction: { reserveTokens: 950 } }, + }); + seedCompactableSession(harness); + const advanced = await store.advance({ memory: "Use committed pre-prompt memory." }, root.manifest.version); + if (advanced.status !== "advanced") throw new Error("Expected harness advance to succeed"); + const controller = new AbortController(); + harness.setResponses([ + (context) => { + expect(context.systemPrompt).toContain("Use committed pre-prompt memory."); + harness.settingsManager.setCompactionEnabled(false); + return fauxAssistantMessage("after pre-prompt compaction"); + }, + ]); + + const prompt = harness.session.prompt("continue", { + signal: controller.signal, + preflightResult: (accepted) => { + if (accepted && cancel) controller.abort(new Error("cancel before commit")); + }, + }); + if (cancel) await expect(prompt).rejects.toThrow("cancel before commit"); + else await prompt; + + expect(harness.session.activeHarnessVersion).toBe( + cancel ? root.manifest.version : advanced.bundle.manifest.version, + ); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength( + cancel ? 0 : 1, + ); + expect(harness.faux.state.callCount).toBe(cancel ? 0 : 1); + }); + + it.each(["pre-prompt", "overflow"] as const)( + "preserves extension prompt overrides through %s refresh", + async (boundary) => { + const { harness, store, root } = await createVersionedCompactionHarness({ + models: [{ id: "faux-1", contextWindow: 1000 }], + settings: { compaction: { reserveTokens: boundary === "pre-prompt" ? 950 : 0 } }, + extensionFactories: [ + (pi) => { + pi.on("before_agent_start", () => ({ systemPrompt: "Extension-owned prompt" })); + }, + ], + }); + seedCompactableSession(harness); + const advanced = await store.advance({ memory: "New base harness memory." }, root.manifest.version); + if (advanced.status !== "advanced") throw new Error("Expected harness advance to succeed"); + const prompts: string[] = []; + harness.setResponses([ + (context) => { + prompts.push(context.systemPrompt ?? ""); + if (boundary === "overflow") + return fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" }); + harness.settingsManager.setCompactionEnabled(false); + return fauxAssistantMessage("after pre-prompt compaction"); + }, + (context) => { + prompts.push(context.systemPrompt ?? ""); + harness.settingsManager.setCompactionEnabled(false); + return fauxAssistantMessage("after overflow compaction"); + }, + ]); + + await harness.session.prompt("continue with the extension prompt"); + + expect(prompts).toEqual(Array(boundary === "overflow" ? 2 : 1).fill("Extension-owned prompt")); + expect(harness.session.activeHarnessVersion).toBe(advanced.bundle.manifest.version); + expect(harness.session.systemPrompt).toContain("New base harness memory."); + }, + ); + + it("restores next-turn context if cancellation arrives during the committed harness refresh", async () => { + const { harness, store, root } = await createVersionedCompactionHarness({ + models: [{ id: "faux-1", contextWindow: 1000 }], + settings: { compaction: { reserveTokens: 950 } }, + }); + seedCompactableSession(harness); + const advanced = await store.advance({ memory: "Committed harness memory." }, root.manifest.version); + if (advanced.status !== "advanced") throw new Error("Expected harness advance to succeed"); + await harness.session.sendCustomMessage( + { customType: "test", content: "pending context survives cancellation", display: false }, + { deliverAs: "nextTurn" }, + ); + const controller = new AbortController(); + vi.spyOn(store, "loadCurrent").mockImplementationOnce(async () => { + controller.abort(new Error("cancel during harness refresh")); + return advanced.bundle; + }); + + await expect(harness.session.prompt("cancel before agent start", { signal: controller.signal })).rejects.toThrow( + "cancel during harness refresh", + ); + + expect(harness.eventsOfType("agent_start")).toHaveLength(0); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(1); + harness.settingsManager.setCompactionEnabled(false); + harness.setResponses([ + (context) => { + expect( + context.messages.filter((message) => + getMessageText(message).includes("pending context survives cancellation"), + ), + ).toHaveLength(1); + return fauxAssistantMessage("restored pending context"); + }, + ]); + await harness.session.prompt("try again"); + expect(harness.session.getLastAssistantText()).toBe("restored pending context"); + }); + + it.each(["pinned", "fixed"])("keeps a %s harness unchanged after compaction", async (kind) => { + const { harness, store, root } = await createVersionedCompactionHarness({ pinHarnessVersion: kind === "pinned" }); + if (kind === "fixed") markFixedHarnessSubagentSession(harness.sessionManager); + seedCompactableSession(harness); + await store.advance({ memory: "Do not apply to pinned sessions." }, root.manifest.version); + + await harness.session.compact(); + + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + }); + + it("reports an automatic refresh failure without undoing compaction or stopping recovery", async () => { + const { harness, store, root } = await createVersionedCompactionHarness(); + seedCompactableSession(harness); + await store.advance({ memory: "Unavailable selected memory." }, root.manifest.version); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selection read failed")); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await expect(sessionInternals._runAutoCompaction("overflow", true)).resolves.toBe(true); + + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "overflow", + aborted: false, + willRetry: true, + result: expect.objectContaining({ summary: expect.stringContaining("versioned compaction summary") }), + errorMessage: "selection read failed", + }); + expect(harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction")).toHaveLength(1); + }); + it("throws when compacting without a model", async () => { const harness = await createHarness(); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-harness-version.test.ts b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts index c54952962..224797ec7 100644 --- a/packages/coding-agent/test/suite/agent-session-harness-version.test.ts +++ b/packages/coding-agent/test/suite/agent-session-harness-version.test.ts @@ -16,7 +16,10 @@ import { harnessVersionDirectoryName, } from "@diffexai/diffex-harness-state"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAgentSessionFromServices, createAgentSessionServices } from "../../src/core/agent-session-services.ts"; import { resolveHarnessWorkspace } from "../../src/core/harness-state-runtime.ts"; +import { SessionManager } from "../../src/core/session-manager.ts"; +import { markFixedHarnessSubagentSession } from "../../src/core/subagents/session-runtime.ts"; import { createHarness, getMessageText, type Harness } from "./harness.ts"; describe("AgentSession harness revisions", () => { @@ -203,7 +206,7 @@ describe("AgentSession harness revisions", () => { ); }); - it("refreshes memory and evolved skills from one selected revision", async () => { + it("explicitly adopts memory and evolved skills from one selected revision", async () => { const { harness, store, rootVersion, telemetry } = await createVersionedHarness(); const workspace = await resolveHarnessWorkspace(harness.tempDir); const skillContent = @@ -230,6 +233,7 @@ describe("AgentSession harness revisions", () => { }, ]); + await harness.session.switchHarnessVersion(revision.manifest.version); await harness.session.prompt("observe the atomic revision"); expect(harness.session.activeHarnessVersion).toBe(revision.manifest.version); @@ -274,7 +278,7 @@ describe("AgentSession harness revisions", () => { expect(harness.session.createSkillReferences()).toEqual([]); }); - it("loads a manually assembled skill revision and restores the prior catalog at the next prompt", async () => { + it("pins a manually assembled skill revision until explicitly restoring the prior catalog", async () => { const { harness, store, rootVersion, telemetry, agentDir } = await createVersionedHarness(); const root = await store.loadVersion(rootVersion); const workspace = await resolveHarnessWorkspace(harness.tempDir); @@ -296,12 +300,17 @@ describe("AgentSession harness revisions", () => { providerPrompts.push(context.systemPrompt ?? ""); return fauxAssistantMessage("manual revision"); }, + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("still pinned revision"); + }, (context) => { providerPrompts.push(context.systemPrompt ?? ""); return fauxAssistantMessage("restored revision"); }, ]); + await harness.session.switchHarnessVersion(revision); await harness.session.prompt("trace the selected skill"); expect(harness.session.activeHarnessVersion).toBe(revision); @@ -339,12 +348,21 @@ describe("AgentSession harness revisions", () => { expect(rolledBack.status).toBe("selected"); expect(harness.session.activeHarnessVersion).toBe(revision); - await harness.session.prompt("observe the restored catalog"); + await harness.session.prompt("observe the pinned catalog after background rollback"); + + expect(harness.session.activeHarnessVersion).toBe(revision); + expect(providerPrompts[1]).toContain("Use the manually assembled harness revision."); + expect(providerPrompts[1]).toContain("manual-review"); + expect(harness.session.createSkillReferences()).toEqual([reference]); + + await harness.session.switchHarnessVersion(rootVersion); + await harness.session.prompt("observe the explicitly restored catalog"); expect(harness.session.activeHarnessVersion).toBe(rootVersion); - expect(providerPrompts[1]).not.toContain("Use the manually assembled harness revision."); - expect(providerPrompts[1]).not.toContain("manual-review"); + expect(providerPrompts[2]).not.toContain("Use the manually assembled harness revision."); + expect(providerPrompts[2]).not.toContain("manual-review"); expect(harness.session.createSkillReferences()).toEqual([]); + expect(harness.getPendingResponseCount()).toBe(0); }); it("selects complete skill-only descendants and restores exact ancestor catalogs in provider prompts", async () => { @@ -473,7 +491,7 @@ describe("AgentSession harness revisions", () => { expect((await store.loadCurrent()).manifest.version).toBe(one.manifest.version); }); - it("pins in-flight tool continuations across a skill-only selection change", async () => { + it("pins tool continuations and later idle requests across a skill-only selection change", async () => { const { harness, store, one, updated } = await createSkillRevisionChain(); await harness.session.switchHarnessVersion(one.manifest.version); const [skill] = one.state.skills; @@ -510,14 +528,15 @@ describe("AgentSession harness revisions", () => { release.resolve(); await inFlight; } - await harness.session.prompt("observe the fresh selection"); + await harness.session.prompt("observe the pinned selection on a later idle request"); expect(providerPrompts).toHaveLength(3); expectRevisionPrompt(providerPrompts[0] ?? "", one); expectRevisionPrompt(providerPrompts[1] ?? "", one); expect(readResult).toContain("# Original alpha-review"); - expectRevisionPrompt(providerPrompts[2] ?? "", updated); - expect(harness.session.activeHarnessVersion).toBe(updated.manifest.version); + expectRevisionPrompt(providerPrompts[2] ?? "", one); + expect(harness.session.activeHarnessVersion).toBe(one.manifest.version); + expect((await store.loadCurrent()).manifest.version).toBe(updated.manifest.version); expect(harness.getPendingResponseCount()).toBe(0); }); @@ -573,14 +592,15 @@ describe("AgentSession harness revisions", () => { expect(userMessages[0]).toContain(`version="${reference.version}"`); expect(userMessages[0]).toContain(`harness-revision="${one.manifest.version}"`); expect(userMessages[0]).not.toContain("Updated"); - expectRevisionPrompt(providerPrompts[2] ?? "", updated); + expectRevisionPrompt(providerPrompts[2] ?? "", one); expect(userMessages[1]).toBe("fresh review"); - expect(harness.session.activeHarnessVersion).toBe(updated.manifest.version); + expect(harness.session.activeHarnessVersion).toBe(one.manifest.version); + expect((await store.loadCurrent()).manifest.version).toBe(updated.manifest.version); expect(harness.getPendingResponseCount()).toBe(0); }, ); - it("rejects an evolved reference after the selected harness changes", async () => { + it("rejects an evolved reference after explicitly switching the active harness", async () => { const { harness, store, rootVersion } = await createVersionedHarness(); const workspace = await resolveHarnessWorkspace(harness.tempDir); const first = await store.saveVersion( @@ -614,6 +634,7 @@ describe("AgentSession harness revisions", () => { first.manifest.version, ); await store.selectVersion(second.manifest.version); + await harness.session.switchHarnessVersion(second.manifest.version); await expect(harness.session.prompt(`${reference.reference}\n\nreview this`)).rejects.toThrow( "is not part of the active harness revision", @@ -635,7 +656,7 @@ describe("AgentSession harness revisions", () => { expect((await store.loadCurrent()).manifest.version).toBe(rootVersion); }); - it("refreshes the selected harness before the next admitted request", async () => { + it("keeps idle prompt admission pinned when the selected harness advances", async () => { const { harness, store, rootVersion } = await createVersionedHarness(); const providerPrompts: string[] = []; harness.setResponses([ @@ -656,12 +677,215 @@ describe("AgentSession harness revisions", () => { await harness.session.prompt("second request"); - expect(harness.session.activeHarnessVersion).toBe(advance.bundle.manifest.version); + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + expect(providerPrompts).toHaveLength(2); expect(providerPrompts[0]).not.toContain(" { + const { harness, store, rootVersion, agentDir } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "Use the construction selection." }, rootVersion); + await store.selectVersion(successor.manifest.version); + const createSession = async (options: { pinHarnessVersion?: boolean; fixed?: boolean } = {}) => { + const services = await createAgentSessionServices({ + cwd: harness.tempDir, + agentDir, + harnessStateStore: store, + harnessVersionBundle: await store.loadVersion(rootVersion), + }); + const sessionManager = SessionManager.inMemory(); + if (options.fixed) markFixedHarnessSubagentSession(sessionManager); + const created = await createAgentSessionFromServices({ + services, + sessionManager, + subagents: false, + pinHarnessVersion: options.pinHarnessVersion, + }); + return created.session; + }; + + const regular = await createSession(); + const pinned = await createSession({ pinHarnessVersion: true }); + const fixed = await createSession({ fixed: true }); + try { + expect(regular.activeHarnessVersion).toBe(successor.manifest.version); + expect(pinned.activeHarnessVersion).toBe(rootVersion); + expect(fixed.activeHarnessVersion).toBe(rootVersion); + } finally { + await Promise.all([regular.dispose(), pinned.dispose(), fixed.dispose()]); + } + }); + + it("adopts the selected revision in a fresh /clear-equivalent session from the same cwd services", async () => { + const { harness, store, rootVersion, agentDir, one, updated } = await createSkillRevisionChain(); + const services = await createAgentSessionServices({ + cwd: harness.tempDir, + agentDir, + modelRuntime: harness.session.modelRuntime, + settingsManager: harness.settingsManager, + resourceLoaderOptions: { + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }, + harnessStateStore: store, + harnessVersionBundle: await store.loadVersion(rootVersion), + }); + await store.selectVersion(one.manifest.version); + const providerPrompts: string[] = []; + harness.setResponses( + Array.from({ length: 3 }, () => (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("observed session revision"); + }), + ); + const original = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(harness.tempDir), + model: harness.getModel(), + subagents: false, + }); + // Isolate session lifecycle requests from the SDK's private scoring continuations. + original.session.agent.preparePrivateContinuation = undefined; + try { + await original.session.prompt("before background selection"); + await store.selectVersion(updated.manifest.version); + await original.session.prompt("remain pinned before clear"); + + expect(original.session.activeHarnessVersion).toBe(one.manifest.version); + expect(original.session.messages.length).toBeGreaterThan(0); + expectRevisionPrompt(providerPrompts[0] ?? "", one); + expectRevisionPrompt(providerPrompts[1] ?? "", one); + } finally { + await original.session.dispose(); + } + + // /clear replaces the session with a new SessionManager in the same cwd. + const cleared = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(harness.tempDir), + sessionStartEvent: { type: "session_start", reason: "new" }, + model: harness.getModel(), + subagents: false, + }); + cleared.session.agent.preparePrivateContinuation = undefined; + try { + expect(cleared.session.activeHarnessVersion).toBe(updated.manifest.version); + expect(cleared.session.messages).toEqual([]); + await cleared.session.prompt("observe the selected revision after clear"); + + expectRevisionPrompt(providerPrompts[2] ?? "", updated); + expect(cleared.session.createSkillReferences()).toEqual( + updated.state.skills.map((skill) => + expect.objectContaining({ name: skill.name, harnessRevision: updated.manifest.version }), + ), + ); + expect(harness.getPendingResponseCount()).toBe(0); + } finally { + await cleared.session.dispose(); + } + }); + + it("falls back to the built-in harness when selected state cannot be read during construction", async () => { + const { harness, store, rootVersion, agentDir } = await createVersionedHarness(); + const services = await createAgentSessionServices({ + cwd: harness.tempDir, + agentDir, + harnessStateStore: store, + harnessVersionBundle: await store.loadVersion(rootVersion), + }); + vi.spyOn(store, "loadCurrent").mockRejectedValueOnce(new Error("selected state is unavailable")); + const created = await createAgentSessionFromServices({ + services, + sessionManager: SessionManager.inMemory(), + subagents: false, + }); + try { + expect(created.session.activeHarnessVersion).toBeUndefined(); + expect(created.harnessStateWarning).toBe( + "Harness state could not be activated: selected state is unavailable. Continuing with the built-in harness.", + ); + } finally { + await created.session.dispose(); + } + }); + + it("keeps the active memory and complete skill catalog pinned after an idle reload", async () => { + const { harness, store, one, updated } = await createSkillRevisionChain(); + await harness.session.switchHarnessVersion(one.manifest.version); + const activeBundle = harness.session.harnessVersionBundle; + const references = harness.session.createSkillReferences(); + await store.selectVersion(updated.manifest.version); + + await harness.session.reload(); + + expect(harness.session.harnessVersionBundle).toBe(activeBundle); + expectRevisionPrompt(harness.session.systemPrompt, one); + expect(harness.session.createSkillReferences()).toEqual(references); + expect((await store.loadCurrent()).manifest.version).toBe(updated.manifest.version); + expect(harness.getPendingResponseCount()).toBe(0); + }); + + it("reloads without reading a selected revision that is unavailable", async () => { + const { harness, store, rootVersion } = await createVersionedHarness(); + const successor = await store.saveVersion({ memory: "Do not activate during reload." }, rootVersion); + await store.selectVersion(successor.manifest.version); + const loadCurrent = vi.spyOn(store, "loadCurrent").mockRejectedValue(new Error("selection read failed")); + + await expect(harness.session.reload()).resolves.toBeUndefined(); + + expect(loadCurrent).not.toHaveBeenCalled(); + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + expect(harness.session.systemPrompt).not.toContain("Do not activate during reload."); + }); + + it("retains the active harness when resource reload fails", async () => { + const { harness, store, one, updated } = await createSkillRevisionChain(); + await harness.session.switchHarnessVersion(one.manifest.version); + const activeBundle = harness.session.harnessVersionBundle; + await store.selectVersion(updated.manifest.version); + vi.spyOn(harness.session.resourceLoader, "reload").mockRejectedValueOnce(new Error("resource reload failed")); + + await expect(harness.session.reload()).rejects.toThrow("resource reload failed"); + + expect(harness.session.harnessVersionBundle).toBe(activeBundle); + expectRevisionPrompt(harness.session.systemPrompt, one); + }); + + it("rejects reload before rebuilding runtime while work is active", async () => { + const { harness } = await createVersionedHarness(); + const sessionInternals = harness.session as unknown as { _buildRuntime: () => void }; + const rebuild = vi.spyOn(sessionInternals, "_buildRuntime"); + vi.spyOn(harness.session, "isStreaming", "get").mockReturnValue(true); + + await expect(harness.session.reload()).rejects.toThrow("Cannot reload while the session is busy."); + + expect(rebuild).not.toHaveBeenCalled(); + }); + + it("rejects reload before rebuilding runtime while a continuation is queued", async () => { + const { harness } = await createVersionedHarness(); + const sessionInternals = harness.session as unknown as { _buildRuntime: () => void }; + const rebuild = vi.spyOn(sessionInternals, "_buildRuntime"); + harness.session.agent.followUp({ + role: "custom", + customType: "test", + content: [{ type: "text", text: "queued continuation" }], + display: false, + timestamp: Date.now(), + }); + + await expect(harness.session.reload()).rejects.toThrow("Cannot reload while the session is busy."); + + expect(rebuild).not.toHaveBeenCalled(); }); - it("keeps an in-flight request pinned while the next request refreshes", async () => { + it("keeps in-flight and later requests pinned until an explicit switch", async () => { const { harness, store, rootVersion } = await createVersionedHarness(); const providerPrompts: string[] = []; let signalFirstStarted: (() => void) | undefined; @@ -680,7 +904,11 @@ describe("AgentSession harness revisions", () => { }, (context) => { providerPrompts.push(context.systemPrompt ?? ""); - return fauxAssistantMessage("after activation"); + return fauxAssistantMessage("still pinned"); + }, + (context) => { + providerPrompts.push(context.systemPrompt ?? ""); + return fauxAssistantMessage("after explicit activation"); }, ]); @@ -707,16 +935,25 @@ describe("AgentSession harness revisions", () => { resolveFirst?.(fauxAssistantMessage("before activation")); await inFlight; - await harness.session.prompt("request admitted after activation"); + await harness.session.prompt("request admitted after background selection"); expect(providerPrompts[0]).not.toContain("Use memory selected during the request."); expect(providerPrompts[0]).not.toContain("later-review"); - expect(providerPrompts[1]).toContain("Use memory selected during the request."); - expect(providerPrompts[1]).toContain("later-review"); + expect(providerPrompts[1]).not.toContain("Use memory selected during the request."); + expect(providerPrompts[1]).not.toContain("later-review"); + expect(harness.session.activeHarnessVersion).toBe(rootVersion); + + await harness.session.switchHarnessVersion(advance.bundle.manifest.version); + await harness.session.prompt("request admitted after explicit activation"); + + expect(providerPrompts).toHaveLength(3); + expect(providerPrompts[2]).toContain("Use memory selected during the request."); + expect(providerPrompts[2]).toContain("later-review"); expect(harness.session.activeHarnessVersion).toBe(advance.bundle.manifest.version); + expect(harness.getPendingResponseCount()).toBe(0); }); - it("reports a live activation to the process lifecycle observer", async () => { + it("reports explicit activation, but not background selection, to the process lifecycle observer", async () => { const activatedVersions: HarnessVersion[] = []; const agentDir = join(tmpdir(), `diffex-harness-observer-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(agentDir, { recursive: true }); @@ -739,7 +976,12 @@ describe("AgentSession harness revisions", () => { expect(successor.status).toBe("advanced"); if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); - await harness.session.prompt("observe the selected harness"); + await harness.session.prompt("observe the pinned harness"); + + expect(activatedVersions).toEqual([]); + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + + await harness.session.switchHarnessVersion(successor.bundle.manifest.version); expect(activatedVersions).toEqual([successor.bundle.manifest.version]); }); diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index d38db5039..1d6d98c03 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -719,7 +719,7 @@ describe("AgentSession prompt characterization", () => { expect(harness.faux.state.callCount).toBe(0); }); - it("observes cancellation after the awaited harness refresh boundary", async () => { + it("admits a signaled prompt without reading a selected harness pointer that would block", async () => { const agentDir = join(tmpdir(), `diffex-prompt-signal-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(agentDir, { recursive: true }); tempDirs.push(agentDir); @@ -727,24 +727,39 @@ describe("AgentSession prompt characterization", () => { const root = await store.initialize(); const harness = await createHarness({ harnessStateStore: store, harnessVersionBundle: root }); harnesses.push(harness); - const loadStarted = deferred(); + const successor = await store.saveVersion( + { memory: "Do not adopt during prompt admission." }, + root.manifest.version, + ); + await store.selectVersion(successor.manifest.version); const releaseLoad = deferred(); - const loadCurrent = store.loadCurrent.bind(store); - vi.spyOn(store, "loadCurrent").mockImplementation(async () => { - loadStarted.resolve(); + const loadCurrent = vi.spyOn(store, "loadCurrent").mockImplementation(async () => { await releaseLoad.promise; - return loadCurrent(); + return successor; }); + let providerPrompt = ""; + harness.setResponses([ + (context) => { + providerPrompt = context.systemPrompt ?? ""; + return fauxAssistantMessage("pinned response"); + }, + ]); const controller = new AbortController(); - const reason = new Error("cancel harness refresh"); - const prompt = harness.session.prompt("refresh", { signal: controller.signal }); - await loadStarted.promise; - controller.abort(reason); - releaseLoad.resolve(); + const prompt = harness.session.prompt("remain pinned", { signal: controller.signal }); + try { + await vi.waitFor(() => expect(harness.faux.state.callCount).toBe(1)); + } finally { + // Release a regressed pointer read so a failing assertion cannot strand prompt cleanup. + releaseLoad.resolve(); + await prompt; + } - await expect(prompt).rejects.toBe(reason); - expect(harness.session.messages).toEqual([]); - expect(harness.faux.state.callCount).toBe(0); + expect(loadCurrent).not.toHaveBeenCalled(); + expect(harness.session.activeHarnessVersion).toBe(root.manifest.version); + expect(providerPrompt).not.toContain("Do not adopt during prompt admission."); + expect(harness.session.messages.map((message) => message.role)).toEqual(["user", "assistant"]); + expect(getMessageText(harness.session.messages[1])).toBe("pinned response"); + expect(harness.getPendingResponseCount()).toBe(0); }); it("propagates cancellation through authentication", async () => { diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index f7e346198..0d2369fc6 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -81,6 +81,7 @@ export interface HarnessOptions { sessionStartEvent?: SessionStartEvent; harnessStateStore?: HarnessStateStore; harnessVersionBundle?: HarnessVersionBundle; + pinHarnessVersion?: boolean; telemetryContext?: TelemetryContext; lifecycleObserver?: AgentSessionLifecycleObserver; } @@ -208,6 +209,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise { expect(await env.points()).toEqual([-1, -1, -2.5]); expect(await env.points(updated.manifest.version)).toEqual([0, undefined, -2.5]); expect(await env.store.loadCurrent()).toEqual(updated); + expect(env.session.activeHarnessVersion).toBe(env.bundle.manifest.version); + // Background selection does not adopt packages; explicitly switch before scoring the new revision. + await env.session.switchHarnessVersion(updated.manifest.version); + expect(env.session.activeHarnessVersion).toBe(updated.manifest.version); env.harness.setResponses([fauxAssistantMessage("New version output."), report([])]); await env.session.prompt("Check the new version."); expect(await env.points(updated.manifest.version)).toEqual([-1, undefined, -3.5]); diff --git a/packages/coding-agent/test/suite/subagent-persistence.test.ts b/packages/coding-agent/test/suite/subagent-persistence.test.ts index d11356e2c..21d4098bd 100644 --- a/packages/coding-agent/test/suite/subagent-persistence.test.ts +++ b/packages/coding-agent/test/suite/subagent-persistence.test.ts @@ -691,7 +691,7 @@ describe("durable subagent lifecycle", () => { }); }); - it.each(["reload", "manual harness switch", "prompt-boundary harness refresh"] as const)( + it.each(["reload", "manual harness switch", "compaction-boundary harness refresh"] as const)( "writes an empty reset barrier through %s", async (boundary) => { const root = await createRoot(); @@ -709,10 +709,38 @@ describe("durable subagent lifecycle", () => { const successor = await store.saveVersion({ memory: "manual successor" }, currentVersion); await root.session.switchHarnessVersion(successor.manifest.version); } else { - const successor = await store.advance({ memory: "prompt successor" }, currentVersion); + const successor = await store.advance({ memory: "compaction successor" }, currentVersion); expect(successor.status).toBe("advanced"); - root.harness.setResponses([fauxAssistantMessage("parent response")]); - await root.session.prompt("activate selected harness"); + if (successor.status !== "advanced") throw new Error("Expected harness advance to succeed"); + let providerPrompt = ""; + root.harness.setResponses([ + (context) => { + providerPrompt = context.systemPrompt ?? ""; + return fauxAssistantMessage("parent response"); + }, + ]); + await root.session.prompt("keep the active harness and child roster pinned"); + + expect(root.session.activeHarnessVersion).toBe(currentVersion); + expect(providerPrompt).not.toContain("compaction successor"); + expect(root.session.getSubagents()).toEqual([{ name: "worker", status: "idle", task: "finish" }]); + expect(readSelectedBranchSubagentRoster(root.sessionManager).children).toEqual([ + expect.objectContaining({ + kind: "restorable", + snapshot: expect.objectContaining({ + name: "worker", + config: expect.objectContaining({ harnessRevision: currentVersion }), + }), + }), + ]); + + root.harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + root.harness.setResponses([fauxAssistantMessage("compacted before adopting the selected harness")]); + await root.session.compact(); + + expect(root.session.activeHarnessVersion).toBe(successor.bundle.manifest.version); + expect(root.session.systemPrompt).toContain("compaction successor"); + expect(root.sessionManager.getEntries().some((entry) => entry.type === "compaction")).toBe(true); } }