From 4776e5983fe591b61721462961ee8428d2dcfeed Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:51:06 +0800 Subject: [PATCH 1/4] chore: record delivery binding for pruning-continuity --- .specgit.yaml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/.specgit.yaml b/.specgit.yaml index 71fb97a9..0aa5f76c 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,17 +1,13 @@ version: 1 -delivery: request-safe-compression +delivery: pruning-continuity context: kind: branch - branch: fix/34-request-safe-compression + branch: fix/38-pruning-continuity issues: - - 34 - - 35 - - 36 + - 38 + - 39 issueKinds: - - issue: 34 + - issue: 38 kind: kind::fix - - issue: 35 - kind: kind::fix - - issue: 36 + - issue: 39 kind: kind::test -pr: 37 From d41701f678c4c4e5270be460764f9d3c65b8968f Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:51:13 +0800 Subject: [PATCH 2/4] chore: record delivery binding for pruning-continuity --- .specgit.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.specgit.yaml b/.specgit.yaml index 0aa5f76c..7271f949 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -11,3 +11,4 @@ issueKinds: kind: kind::fix - issue: 39 kind: kind::test +pr: 40 From f6865c659f5b4befb19c60e22d6621bacba26e70 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 11:56:33 +0800 Subject: [PATCH 3/4] fix: preserve pruning with host reference markers --- lib/dtc/engine.ts | 26 +++++++--- tests/projection.test.ts | 101 ++++++++++++++++++++++++++++++++++++ tests/request-hooks.test.ts | 50 ++++++++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/lib/dtc/engine.ts b/lib/dtc/engine.ts index c3aa706b..1201ab02 100644 --- a/lib/dtc/engine.ts +++ b/lib/dtc/engine.ts @@ -65,10 +65,10 @@ function hasAttachments(value: unknown): boolean { return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null } -function attachmentsPresent(part: PartLike): boolean { +function attachmentsPresent(part: PartLike, clearedStateAttachments = false): boolean { return ( hasAttachments(part.attachments) || - hasAttachments(part.state?.attachments) || + (!clearedStateAttachments && hasAttachments(part.state?.attachments)) || hasAttachments(part.state?.metadata?.attachments) ) } @@ -83,8 +83,17 @@ function jsonTokens(value: unknown): number | undefined { } /** Unknown media or host content is not assigned an invented token cost. */ -function estimatePart(part: PartLike): number | undefined { +function estimatePart(part: PartLike, role: string | undefined): number | undefined { if (!part || typeof part !== "object") return undefined + // User references retain UI markers after the host expands their content + // into separate text parts. These markers are omitted from model requests. + if ( + role === "user" && + (part.type === "agent" || + (part.type === "file" && + (part.mime === "text/plain" || part.mime === "application/x-directory"))) + ) + return 0 if (STRUCTURAL_PARTS.has(part.type ?? "")) return 0 // These host-owned user parts serialize as fixed text. Their presence // after native compaction must not disable every future projection. @@ -98,8 +107,13 @@ function estimatePart(part: PartLike): number | undefined { if (typeof part.text !== "string") return undefined return PART_OVERHEAD + estimateTokens(part.text) } - if (part.type !== "tool" || !part.state || attachmentsPresent(part)) return undefined + if (part.type !== "tool" || !part.state) return undefined const state = part.state + // The host omits only state.attachments for already-cleared completed + // assistant tools. Inputs, output shape and unfamiliar metadata still count. + const clearedStateAttachments = + role === "assistant" && state.status === "completed" && !!state.time?.compacted + if (attachmentsPresent(part, clearedStateAttachments)) return undefined const inputTokens = jsonTokens(state.input ?? {}) if (inputTokens === undefined) return undefined let tokens = @@ -140,7 +154,7 @@ export function estimateMessages(messages: readonly MessageLike[]): number | und } total += MESSAGE_OVERHEAD for (const part of message.parts) { - const tokens = estimatePart(part) + const tokens = estimatePart(part, message.info?.role) if (tokens === undefined) return undefined total += tokens } @@ -162,7 +176,7 @@ function toolSteps(messages: readonly MessageLike[]): ToolStep[] { steps.push(current) current = { tools: [], tokens: MESSAGE_OVERHEAD } } - current.tokens += estimatePart(part) ?? 0 + current.tokens += estimatePart(part, message.info?.role) ?? 0 if (part.type === "tool") current.tools.push({ message: messageIndex, part: partIndex }) } if (current.tools.length > 0) steps.push(current) diff --git a/tests/projection.test.ts b/tests/projection.test.ts index 0a3cd82a..7f7e08da 100644 --- a/tests/projection.test.ts +++ b/tests/projection.test.ts @@ -120,6 +120,66 @@ test("compacted output estimation still includes the complete tool input", () => assert.equal(estimateMessages(source), compacted, "stored compacted output is not sent") }) +test("attachments already cleared by the host do not disable further output projection", () => { + for (const force of [false, true]) { + const source = conversation(8) + const compacted = tools(source)[0]! + compacted.state!.time!.compacted = 999 + compacted.state!.input!.content = "input ".repeat(10000) + const baseline = run(source, { force }) + compacted.state!.attachments = [ + { type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }, + ] + const before = structuredClone(source) + const projected = run(source, { force }) + assert.ok(projected.stats.foldedTools > 0) + assert.deepEqual(projected.stats, baseline.stats) + assert.ok(projected.stats.estimatedAfter! > 15000, "full compacted-tool input still counts") + assert.deepEqual(tools(projected.messages)[0], compacted) + assert.deepEqual(source, before) + for (const part of tools(projected.messages).slice(1)) delete part.state!.time!.compacted + assert.deepEqual(projected.messages, before, "existing markers and attachments stay intact") + } +}) + +test("cleared attachments do not bypass tool payload or host-role validation", () => { + const changes: Array<(message: MessageLike, part: PartLike) => void> = [ + (message) => delete message.info!.role, + (message) => { + message.info!.role = "user" + }, + (_, part) => delete part.state!.output, + (_, part) => { + part.state!.input!.cycle = part.state!.input + }, + (_, part) => { + part.state!.status = "running" + }, + (_, part) => { + part.state!.time!.compacted = 0 + }, + (_, part) => { + part.attachments = ["unfamiliar"] + }, + (_, part) => { + part.state!.metadata!.attachments = ["unfamiliar"] + }, + ] + for (const change of changes) { + const source = conversation(8) + const part = tools(source)[0]! + part.state!.time!.compacted = 999 + part.state!.attachments = [ + { type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }, + ] + change(source[1]!, part) + const projected = run(source, { force: true }) + assert.equal(projected.stats.skipped, "unknown-content") + assert.equal(projected.stats.foldedTools, 0) + assert.deepEqual(projected.messages, source) + } +}) + test("only verified successful output contracts are eligible", () => { const source = conversation(18) const parts = tools(source) @@ -190,6 +250,47 @@ test("file media and unfamiliar parts do not produce a false budget success", () } }) +test("user file, directory and agent reference markers do not disable old-output projection", () => { + const markers: PartLike[] = [ + { type: "file", mime: "text/plain", url: "file:///repo/source.ts" }, + { type: "file", mime: "application/x-directory", url: "file:///repo/src" }, + { type: "agent", name: "explore" }, + ] + for (const marker of markers) { + for (const force of [false, true]) { + const source = conversation(8) + const baseline = run(source, { force }) + source[0]!.parts!.push(marker) + const before = structuredClone(source) + const projected = run(source, { force }) + assert.ok(projected.stats.foldedTools > 0) + assert.deepEqual(projected.stats, baseline.stats) + assert.deepEqual(projected.messages[0], before[0]) + assert.deepEqual(source, before) + for (const part of tools(projected.messages)) delete part.state!.time!.compacted + assert.deepEqual(projected.messages, before, "only native compacted markers may differ") + } + } +}) + +test("reference markers in unverified message roles remain unknown content", () => { + for (const role of ["assistant", "system", undefined]) { + for (const marker of [ + { type: "file", mime: "text/plain", url: "file:///repo/source.ts" }, + { type: "file", mime: "application/x-directory", url: "file:///repo/src" }, + { type: "agent", name: "explore" }, + ]) { + const source = conversation(8) + source[0]!.info!.role = role + source[0]!.parts!.push(marker) + const projected = run(source, { force: true }) + assert.equal(projected.stats.skipped, "unknown-content") + assert.equal(projected.stats.foldedTools, 0) + assert.deepEqual(projected.messages, source) + } + } +}) + test("small outputs and existing native markers remain unchanged", () => { const source = conversation(3) const parts = tools(source) diff --git a/tests/request-hooks.test.ts b/tests/request-hooks.test.ts index 67fb84a8..233de9be 100644 --- a/tests/request-hooks.test.ts +++ b/tests/request-hooks.test.ts @@ -118,6 +118,56 @@ test("transform uses this request's model and commits into the original host arr assert.equal(deps.calls(), 2, "each request resolves its current model") }) +test("host reference markers and cleared attachments preserve normal and forced projection", async () => { + const changes: Array<(messages: any[]) => void> = [ + (messages) => + messages[0].parts.push({ + type: "file", + mime: "text/plain", + url: "file:///repo/source.ts", + }), + (messages) => + messages[0].parts.push({ + type: "file", + mime: "application/x-directory", + url: "file:///repo/src", + }), + (messages) => messages[0].parts.push({ type: "agent", name: "explore" }), + (messages) => { + const state = messages[1].parts[0].state + state.time.compacted = 999 + state.attachments = [ + { type: "file", mime: "image/png", url: "data:image/png;base64,AAAA" }, + ] + }, + ] + for (const change of changes) { + for (const force of [false, true]) { + const deps = build() + const messages = history(force ? "large" : "small") + change(messages) + const before = structuredClone(messages) + const output = { messages } + if (force) deps.state.requestFold("ses_a") + await deps.transform({}, output) + assert.equal(output.messages, messages) + let newlyFolded = 0 + for (const [index, message] of messages.entries()) { + for (const [partIndex, part] of message.parts.entries()) { + const original = before[index].parts[partIndex] + if (part.state?.time?.compacted && !original.state?.time?.compacted) { + newlyFolded++ + delete part.state.time.compacted + } + } + } + assert.ok(newlyFolded > 0) + assert.deepEqual(messages, before, "only new compacted markers may differ") + assert.equal(deps.state.consumeFold("ses_a"), false) + } + } +}) + test("one forced normal request survives compaction and does not become permanent", async () => { const deps = build() deps.state.requestFold("ses_a") From 754ce1e20dc8ef5ffd403e1d8edee7d0f32489c2 Mon Sep 17 00:00:00 2001 From: Lex Date: Fri, 4 Sep 2026 12:03:49 +0800 Subject: [PATCH 4/4] test: verify pruning and tool continuity under context pressure --- .github/workflows/pr-checks.yml | 2 +- ARCHITECTURE.md | 12 +- README.en.md | 4 + README.md | 4 + package-lock.json | 4 +- package.json | 2 +- scripts/test-host.mjs | 2 +- tests/host/contract.test.mjs | 40 +++- tests/host/public-loop.mjs | 311 +++++++++++++++++++++++++++----- 9 files changed, 324 insertions(+), 57 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 06cb570a..4da201ef 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -105,7 +105,7 @@ jobs: uses: actions/checkout@v7 with: repository: LeXwDeX/OpenCode-GraphAgent - ref: 743d99ff4b79bcafeffc4d5e8624060b3af6ca13 + ref: 8d9972908c308da1836a004cebe27c7c23db1acc path: .host/opencode - name: Setup host runtime diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 81505113..878ff419 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -21,6 +21,8 @@ Recent protection uses native step markers, or a whole assistant message when ma Estimation includes full inputs even when the corresponding output already has a compacted marker. Known compaction/subtask parts use the host's fixed rendered text; interrupted tool errors include the output carried in metadata. Unrecognized media/content yields an unknown estimate, not zero tokens. An unsatisfied budget is reported; it never permits deleting other kinds of information. +Known user reference markers (`file` with `text/plain` or `application/x-directory`, and `agent`) are omitted by the host serializer. Their expanded content is carried in separate text parts and remains fully counted. The markers themselves are preserved. Attachments retained on already-compacted successful tools do not reach the model, so they do not invalidate that cleared-output estimate. This exception does not extend to live media or unfamiliar part/role combinations. + ## Host contract and its limits The V1 transform hook currently receives an empty input object. For ordinary requests, supported hosts resolve the model from the latest ordinary user's explicit model reference before invoking the transform. DCP reads that same provider/model from the host's configured catalog. Conflicting or absent session identity, absent model references, failed catalog reads and invalid limits all retain the original request. @@ -34,6 +36,8 @@ min(model.limit.input ?? model.limit.context, The output-token ceiling defaults to 32,000, honoring the host-process environment override when valid. The default `targetRatio` leaves headroom for system and tool definitions added later. This is an estimate, not the final provider-token count: later plugins may change model options, and system/tool definitions are not exposed by this hook. Native overflow handling remains necessary. +The host can act on the previous response's reported usage before invoking the next ordinary transform. Pruning is therefore not guaranteed an extra rescue pass before native compaction. In a small window, the protected recent steps plus system/tool overhead may already consume the usable capacity; preserving those steps can leave no opportunity to prune. Automatic compaction and continuation must be tested with increasing reported usage as well as deterministic projection tests. A successful continuation alone does not prove that every tool completed: assert tool status and exit code too. + Native compaction calls `experimental.session.compacting` before its messages transform. The plugin sets and consumes a session-specific skip before doing any catalog lookup or projection. The later compaction `chat.params` clears a guard left by an empty/unidentified summary history; it never supplies a budget for future chat. If compaction aborts before either call, the next identified request skips once and clears the guard. This may send extra history, but never a DCP-folded summary input. A guard must never be evicted while projection continues. If pending-control capacity cannot retain a required compaction guard, projection fails open for the plugin instance and emits a diagnostic; reload the host instance to resume. Ordinary request execution continues. This protects summary fidelity with bounded memory. @@ -53,11 +57,11 @@ npm run check:package They cover long single-user tasks, complete-step protection, independent read pages and repeated calls, failed and interrupted tools, unknown inputs, model switching, ambiguous identity, one-request controls, capacity and commit failures. -The real-host suite pins [OpenCode-GraphAgent](https://github.com/LeXwDeX/OpenCode-GraphAgent) at `743d99ff4b79bcafeffc4d5e8624060b3af6ca13`. The source revision is checked before execution. Prepare a separate checkout: +The real-host suite pins [OpenCode-GraphAgent](https://github.com/LeXwDeX/OpenCode-GraphAgent) at `8d9972908c308da1836a004cebe27c7c23db1acc`. The source revision is checked before execution. Prepare a separate checkout: ```sh git clone https://github.com/LeXwDeX/OpenCode-GraphAgent.git /tmp/dcp-host -git -C /tmp/dcp-host checkout 743d99ff4b79bcafeffc4d5e8624060b3af6ca13 +git -C /tmp/dcp-host checkout 8d9972908c308da1836a004cebe27c7c23db1acc cd /tmp/dcp-host bun install --frozen-lockfile --ignore-scripts --filter './packages/opencode' cd /path/to/opencode-dynamic-context-pruning @@ -66,7 +70,9 @@ OPENCODE_SOURCE_ROOT=/tmp/dcp-host npm run test:host Node runs the tests; Bun executes the real host workers, matching the host runtime. Component contract tests use real plugin loading/dispatch, message hydration, SQLite storage, provider transforms and SDK serialization. They seed deterministic history and substitute selected service boundaries to inspect exact hook behavior; the compaction component test records processor input instead of executing a model request. -The public HTTP test starts the host's complete default service graph through `Server.listen` and loads the built plugin directly from `opencode.json`. Only the external model HTTP/SSE endpoint is replaced. Public session APIs drive two concurrent 100-step read loops, small/large model budgets, model switching, native summarization and continuation. Assertions inspect actual outgoing model requests and publicly read persisted history. This covers lifecycle behavior that direct hook tests cannot prove. +The public HTTP scenarios start the host's complete default service graph through `Server.listen` and load the built plugin directly from `opencode.json`. Only the external model HTTP/SSE endpoint is replaced. The original concurrent 100-step scenario uses fixed low reported usage and disables automatic compaction to isolate model switching, manual native summarization and history fidelity. + +Additional scenarios retain the host's automatic compaction/pruning defaults and report usage proportional to outgoing request size. At 64K, both ordinary prompts and file-reference prompts must exhibit DCP pruning before the first native summary, then finish after automatic continuation. At 32K, protected recent content may leave no room for DCP; repeated native summaries must still preserve successful tool execution. The Native LLM scenario reports high usage alongside a slow shell call and requires its successful exit before compaction, then separately checks that explicit cancellation still stops a longer command. Assertions inspect actual outgoing model requests, successful tool outputs/exit codes and publicly read persisted history. This covers lifecycle behavior that direct hook tests cannot prove; simulated usage does not certify a provider's tokenizer or hard context limit. This is a pinned host contract test, not a claim that every future host or model provider has been exercised. CI also typechecks/builds/imports against the minimum and latest V1 plugin/SDK versions. The required `opencode-compatibility` aggregate includes the real-host job; SpecGit policy is unchanged. diff --git a/README.en.md b/README.en.md index bc1fb79a..88aa8785 100644 --- a/README.en.md +++ b/README.en.md @@ -16,8 +16,12 @@ Eligible tools are known `read`, `grep`, `glob`, and `bash` with an explicit zer User instructions, assistant text, reasoning signatures, tool inputs, errors, message/part counts, identities and ordering remain unchanged. There is no topic inference, synthetic digest, input reduction, structural merging, or deduplication. Projection is prepared independently and committed only on success. +Host markers for ordinary file, directory, and agent references do not disable pruning. Their expanded text is counted and the markers remain intact. Already-compacted tools are estimated using the host's cleared output, even when stored history retains attachments. Media that still reaches the model and unfamiliar content keep the request unchanged rather than receiving a guessed token cost. + **Folding is lossy output cleanup.** Original outputs remain in stored history. Protected steps, long inputs and system instructions may themselves exceed the budget; DCP then leaves the protection rules intact and lets the host handle native compaction. +The host may start automatic compaction from the previous response's reported usage before the next pruning hook runs. A small context window or large system/tool definitions can therefore trigger a native summary before any old steps become eligible. `targetRatio` limits estimated history, not the final provider request, and insufficient capacity never lowers recent-step protection automatically. Successful summary continuation and settlement of running tools remain host execution contracts. + ## Controls The model-facing `dcp_prune` tool requests one fold on the next ordinary request, subject to the same protections. It returns immediately and does not permanently change policy. diff --git a/README.md b/README.md index da6f2c42..c4c312c5 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,12 @@ DCP 在 OpenCode 发送模型请求之前,按当前模型预算折叠较旧的 用户消息、助手文本、推理及签名、工具输入和错误内容逐字保留。消息和 parts 的数量、顺序、身份及调用配对保持不变。没有话题猜测、机械摘要、输入缩减、调用合并或去重。 +普通 `@文件`、`@目录` 和 `@agent` 引用的宿主标记不会阻止剪枝:引用展开后的文本照常计算,标记原样保留。已被宿主压缩的工具按清理后的输出估算,即使原始历史仍保存附件。实际仍会发送给模型的媒体或未知内容继续保留原文,不猜测其 token 数。 + **折叠是有损的工具输出清理。**过去的输出细节会从本次模型请求中消失,仍可在原始会话中查阅。DCP 不承诺任意长对话都能装入窗口:近期步骤、长输入、受保护内容或系统提示本身可能过大,此时保留保护规则,由宿主原生压缩处理。 +宿主可能根据上一轮真实用量,在下一次剪枝入口之前启动自动压缩。因此,窗口较小或系统提示、工具定义较大时,可能先发生原生摘要,DCP 尚未有可折叠的旧步骤。`targetRatio` 是历史预算比例,不是最终模型请求的硬上限;近期保护不会因窗口不足而自动降低。原生摘要成功后是否续跑、正在执行的工具如何结算,属于宿主执行契约。 + ## 手动控制与原生压缩 模型可调用 `dcp_prune`,请求**下一次普通模型请求**主动折叠符合条件的历史输出。工具立即返回,同样遵守近期和内容保护;请求消费后不影响未来策略,不保存永久加深等级。可向助手提出“调用 dcp_prune 压缩旧工具输出”。 diff --git a/package-lock.json b/package-lock.json index c87147a5..d0684c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lexwdex-org/opencode-dcp", - "version": "6.0.0", + "version": "6.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lexwdex-org/opencode-dcp", - "version": "6.0.0", + "version": "6.0.1", "license": "AGPL-3.0-or-later", "dependencies": { "jsonc-parser": "^3.3.1" diff --git a/package.json b/package.json index c78521f9..7341bdbd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@lexwdex-org/opencode-dcp", - "version": "6.0.0", + "version": "6.0.1", "type": "module", "description": "Request-scoped compression of old successful OpenCode tool outputs", "main": "./dist/index.js", diff --git a/scripts/test-host.mjs b/scripts/test-host.mjs index 392e1b57..d9fb3d72 100644 --- a/scripts/test-host.mjs +++ b/scripts/test-host.mjs @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path" import { fileURLToPath } from "node:url" // This is the host implementation under test, not an SDK-only compatibility check. -export const HOST_COMMIT = "743d99ff4b79bcafeffc4d5e8624060b3af6ca13" +export const HOST_COMMIT = "8d9972908c308da1836a004cebe27c7c23db1acc" const root = resolve(dirname(fileURLToPath(import.meta.url)), "..") const host = process.env.OPENCODE_SOURCE_ROOT && resolve(process.env.OPENCODE_SOURCE_ROOT) diff --git a/tests/host/contract.test.mjs b/tests/host/contract.test.mjs index 0182ca8a..70e97bd8 100644 --- a/tests/host/contract.test.mjs +++ b/tests/host/contract.test.mjs @@ -35,10 +35,10 @@ test("host components: filterCompacted checkpoint prefix remains intact", () => hostScenario("post-compaction-prefix") }) -test("public host HTTP: concurrent real tool loops, model switches, history and native compaction", () => { +function publicHostScenario(scenario = "public-host-loop") { const result = spawnSync( "bun", - [fileURLToPath(new URL("./public-loop.mjs", import.meta.url))], + [fileURLToPath(new URL("./public-loop.mjs", import.meta.url)), scenario], { encoding: "utf8", timeout: 180_000, @@ -47,8 +47,36 @@ test("public host HTTP: concurrent real tool loops, model switches, history and ) assert.ifError(result.error) assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`) - assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)), { - scenario: "public-host-loop", - ok: true, - }) + const report = JSON.parse(result.stdout.trim().split("\n").at(-1)) + assert.equal(report.scenario, scenario) + assert.equal(report.ok, true) + return report +} + +test("public host HTTP: concurrent real tool loops, model switches, history and native compaction", () => { + publicHostScenario() +}) + +test("public host HTTP: default 64K auto compaction follows DCP with plain and file-reference prompts", () => { + const { metrics } = publicHostScenario("automatic-64k") + assert.equal(metrics.length, 2) + for (const result of metrics) { + assert.ok(result.prunedBeforeSummary > 0) + assert.ok(result.summaries > 0) + assert.equal(result.completed, result.tools) + } +}) + +test("public host HTTP: default 32K recent protection permits repeated native summaries and completion", () => { + const { metrics } = publicHostScenario("automatic-32k") + assert.equal(metrics.length, 1) + assert.ok(metrics[0].summaries >= 2) + assert.equal(metrics[0].completed, metrics[0].tools) +}) + +test("public host HTTP: native automatic compaction settles a slow bash; explicit abort still cancels", () => { + const { metrics } = publicHostScenario("native-slow-tool") + assert.equal(metrics.length, 2) + assert.equal(metrics[0].completed, metrics[0].tools) + assert.equal(metrics[1].explicitlyCancelled, true) }) diff --git a/tests/host/public-loop.mjs b/tests/host/public-loop.mjs index 70863e04..25395e28 100644 --- a/tests/host/public-loop.mjs +++ b/tests/host/public-loop.mjs @@ -7,6 +7,16 @@ import { createServer } from "node:net" // A separate Bun process runs the complete host HTTP server with its default // service graph. All mutations below go through the public session API. +const scenario = process.argv[2] ?? "public-host-loop" +const settings = { + "public-host-loop": { steps: 100, context: 32_000, tags: ["A", "B"] }, + "automatic-64k": { steps: 56, context: 64_000, tags: ["A", "B"] }, + "automatic-32k": { steps: 40, context: 32_000, tags: ["A"] }, + "native-slow-tool": { steps: 6, context: 32_000, tags: ["A", "B"] }, +}[scenario] +assert.ok(settings, `unknown public host scenario: ${scenario}`) +const automatic = scenario !== "public-host-loop" +const native = scenario === "native-slow-tool" const hostRoot = process.env.DCP_HOST_ROOT assert.ok(hostRoot, "run this suite with npm run test:host") const scratch = await mkdtemp(join(tmpdir(), "dcp-public-loop-")) @@ -40,20 +50,32 @@ process.env.OPENCODE_MODELS_PATH = join( process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "true" process.env.OPENCODE_DISABLE_LSP_DOWNLOAD = "true" process.env.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM = "true" +process.env.OPENCODE_EXPERIMENTAL_NATIVE_LLM = String(native) process.env.OPENCODE_PRINT_LOGS = "1" process.env.OPENCODE_LOG_LEVEL = "ERROR" const captures = [] const cleared = "[Old tool result content cleared]" -const steps = 100 +const steps = settings.steps +// The model's progress survives native summaries, which deliberately remove old +// tool calls from later requests. Each session has a distinct task tag. +const emitted = { A: 0, B: 0 } +const isSummary = (body) => + [ + "Create a new anchored summary from the conversation history.", + "Update the anchored summary below using the conversation history above.", + ].some((text) => JSON.stringify(body.messages).includes(text)) +// Larger late files make the protected recent steps eventually cross the +// native context boundary, after earlier ordinary outputs were DCP candidates. +const evidenceLines = (step) => (scenario === "automatic-64k" && step >= 32 ? 1_600 : 240) const evidence = (tag, step) => - `EVIDENCE_${tag}_${step}_START\n${"original tool evidence\n".repeat(240)}EVIDENCE_${tag}_${step}_END` -for (const tag of ["A", "B"]) { + `EVIDENCE_${tag}_${step}_START\n${"original tool evidence\n".repeat(evidenceLines(step))}EVIDENCE_${tag}_${step}_END` +for (const tag of settings.tags) { for (let step = 0; step < steps; step++) await writeFile(join(project, `${tag}-${step}.txt`), evidence(tag, step)) } -function sse(model, delta, finish = "stop") { +function sse(model, delta, finish = "stop", usage = 10) { const chunk = (value, reason = null) => ({ id: "chatcmpl-local", object: "chat.completion.chunk", @@ -67,7 +89,7 @@ function sse(model, delta, finish = "stop") { chunk(delta), { ...chunk({}, finish), - usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }, + usage: { prompt_tokens: usage, completion_tokens: 1, total_tokens: usage + 1 }, }, ] .map((item) => `data: ${JSON.stringify(item)}\n\n`) @@ -86,33 +108,34 @@ const modelServer = Bun.serve({ async fetch(request) { assert.equal(new URL(request.url).pathname, "/v1/chat/completions") const body = await request.json() - captures.push(body) const serialized = JSON.stringify(body.messages) + const tag = settings.tags.find((value) => serialized.includes(`DCP_CASE_${value}`)) + let usage = automatic ? Math.ceil(JSON.stringify(body).length / 4) : 10 + // This completed model response already exceeds a 32K input budget. + // Its pending slow tool must settle before automatic compaction begins. + if (native && tag === "A" && emitted.A === 0 && !isSummary(body)) usage = 30_000 + captures.push({ ...body, auditUsage: usage, auditEmitted: tag ? emitted[tag] : 0 }) + const respond = (delta, finish = "stop") => sse(body.model, delta, finish, usage) if (JSON.stringify(body).includes("Generate a title for this conversation")) - return sse(body.model, { content: "Host integration test" }) - const summary = serialized.includes( - "Create a new anchored summary from the conversation history.", - ) - if (summary) - return sse(body.model, { - content: - "## Goal\nPreserve DCP_CASE_A requirements.\n## Progress\nRead all 100 files.\n## Next Steps\nReport complete.", + return respond({ content: "Host integration test" }) + if (isSummary(body)) { + assert.ok(tag, "the native summary must preserve the originating task") + return respond({ + content: automatic + ? `## Goal\nPreserve DCP_CASE_${tag} requirements.\n## Progress\nExecuted ${emitted[tag]} tools.\n## Next Steps\nContinue through tool ${steps}, then report complete.` + : "## Goal\nPreserve DCP_CASE_A requirements.\n## Progress\nRead all 100 files.\n## Next Steps\nReport complete.", }) - if (serialized.includes("DCP_RESUME")) - return sse(body.model, { content: "DCP_RESUME complete." }) - const tag = serialized.includes("DCP_CASE_A") - ? "A" - : serialized.includes("DCP_CASE_B") - ? "B" - : undefined - if (!tag) return sse(body.model, { content: "Host integration test" }) - const calls = body.messages.flatMap((message) => message.tool_calls ?? []) - if (calls.length < steps) { - const step = calls.length + } + if (serialized.includes("DCP_RESUME")) return respond({ content: "DCP_RESUME complete." }) + if (!tag) return respond({ content: "Host integration test" }) + if (emitted[tag] < steps) { + const step = emitted[tag]++ if (step % 25 === 0) - process.stdout.write(`host ${tag}: starting tool ${step + 1}/${steps}\n`) - return sse( - body.model, + process.stdout.write( + `host ${scenario}/${tag}: starting tool ${step + 1}/${steps}\n`, + ) + const slow = native && step === 0 + return respond( { tool_calls: [ { @@ -120,10 +143,18 @@ const modelServer = Bun.serve({ id: `call_${tag}_${step}`, type: "function", function: { - name: "read", - arguments: JSON.stringify({ - filePath: join(project, `${tag}-${step}.txt`), - }), + name: slow ? "bash" : "read", + arguments: JSON.stringify( + slow + ? { + command: + tag === "A" + ? "sleep 2" + : "sleep 30; printf DCP_CANCEL_MISSED", + description: "Isolated slow tool regression", + } + : { filePath: join(project, `${tag}-${step}.txt`) }, + ), }, }, ], @@ -131,7 +162,7 @@ const modelServer = Bun.serve({ "tool_calls", ) } - return sse(body.model, { content: `DCP_CASE_${tag} completed all ${steps} reads.` }) + return respond({ content: `DCP_CASE_${tag} completed all ${steps} tools.` }) }, }) @@ -158,7 +189,7 @@ await writeFile( small_model: "test/large", permission: "allow", lsp: false, - compaction: { auto: false, prune: false, tail_turns: 0 }, + ...(!automatic ? { compaction: { auto: false, prune: false, tail_turns: 0 } } : {}), provider: { test: { name: "Local test", @@ -166,7 +197,10 @@ await writeFile( env: [], npm: "@ai-sdk/openai-compatible", options: { apiKey: "local-test-only", baseURL: `${modelServer.url.origin}/v1` }, - models: { small: model("small", 32_000), large: model("large", 2_000_000) }, + models: { + small: model("small", settings.context), + large: model("large", 2_000_000), + }, }, }, }), @@ -214,8 +248,18 @@ const prompt = (sessionID, tag, modelID) => parts: [ { type: "text", - text: `DCP_CASE_${tag}: Read all 100 evidence files in order and preserve the original constraints.`, + text: `DCP_CASE_${tag}: Execute all ${steps} tools in order and preserve the original constraints.`, }, + ...(scenario === "automatic-64k" && tag === "B" + ? [ + { + type: "file", + mime: "text/plain", + filename: "B-0.txt", + url: pathToFileURL(join(project, "B-0.txt")).href, + }, + ] + : []), ], }) const toolParts = (messages) => @@ -223,12 +267,179 @@ const toolParts = (messages) => const requestsFor = (tag) => captures.filter((body) => JSON.stringify(body.messages).includes(`DCP_CASE_${tag}`)) -try { - const { Server } = await import( - pathToFileURL(join(hostRoot, "packages/opencode/src/server/server.ts")).href - ) - listener = await Server.listen({ hostname: "127.0.0.1", port }) - process.stdout.write("host: HTTP listener ready\n") +async function automaticPressure() { + const metrics = [] + const tags = native ? ["A"] : settings.tags + for (const tag of tags) { + const session = await api("/session", { title: `${scenario} ${tag}` }) + const completed = await prompt(session.id, tag, "small") + const history = await api(`/session/${session.id}/message`) + const parts = toolParts(history) + const requests = requestsFor(tag) + const summaries = requests.filter(isSummary) + const ordinary = requests.filter((body) => !isSummary(body)) + const firstSummary = requests.findIndex(isSummary) + const beforeSummary = requests.slice(0, firstSummary < 0 ? undefined : firstSummary) + const prunedBeforeSummary = beforeSummary.filter((body) => + JSON.stringify(body.messages).includes(cleared), + ).length + for (const request of requests) { + assert.deepEqual( + request.messages + .flatMap((message) => message.tool_calls ?? []) + .map((call) => call.id), + request.messages + .filter((message) => message.role === "tool") + .map((message) => message.tool_call_id), + "every ordinary and summary request keeps tool calls paired with their results", + ) + } + const metric = { + tag, + context: settings.context, + tools: parts.length, + completed: parts.filter((part) => part.state.status === "completed").length, + summaries: summaries.length, + prunedBeforeSummary, + prunedRequests: ordinary.filter((body) => + JSON.stringify(body.messages).includes(cleared), + ).length, + highestUsage: Math.max(...ordinary.map((body) => body.auditUsage)), + } + metrics.push(metric) + process.stdout.write(JSON.stringify(metric) + "\n") + assert.equal( + parts.length, + steps, + "the real host must execute every planned tool exactly once", + ) + assert.equal(emitted[tag], steps) + for (let step = 0; step < steps; step++) { + const part = parts[step] + assert.equal(part.callID, `call_${tag}_${step}`) + assert.equal(part.state.status, "completed", JSON.stringify(part.state)) + assert.equal( + part.state.time.compacted, + undefined, + "request projection must not persist", + ) + if (native && step === 0) { + assert.equal(part.tool, "bash") + assert.equal( + part.state.metadata.exit, + 0, + "automatic compaction must let slow bash exit successfully", + ) + assert.ok(part.state.time.end - part.state.time.start >= 1_900) + } else { + assert.equal(part.tool, "read") + assert.ok(part.state.output.includes(`EVIDENCE_${tag}_${step}_START`)) + assert.ok(part.state.output.includes(`EVIDENCE_${tag}_${step}_END`)) + assert.equal( + part.state.output.match(/original tool evidence/g)?.length, + evidenceLines(step), + "full tool output remains in storage", + ) + } + } + assert.ok( + completed.parts.some( + (part) => + part.type === "text" && + part.text === `DCP_CASE_${tag} completed all ${steps} tools.`, + ), + "the public prompt must complete after automatic continuation", + ) + assert.ok( + summaries.length >= (scenario === "automatic-32k" ? 2 : 1), + "growing reported usage must trigger real native compaction", + ) + assert.ok(summaries[0].auditEmitted < steps, "native summary occurs before task completion") + for (const summary of summaries) { + assert.ok( + !JSON.stringify(summary.messages).includes(cleared), + "native summary must never receive DCP markers", + ) + assert.ok( + summary.messages.some((message) => message.role === "tool"), + "native summary includes actual tool history", + ) + } + assert.ok( + history.some((message) => message.info.summary === true), + "native summaries are persisted", + ) + assert.ok( + ordinary.some((body) => body.tools.some((tool) => tool.function?.name === "dcp_prune")), + "the built plugin must be loaded through the host", + ) + if (scenario === "automatic-64k") { + assert.ok( + prunedBeforeSummary > 0, + `${tag}: DCP must prune before the first native summary`, + ) + const clearedResult = beforeSummary + .flatMap((body) => body.messages) + .find( + (message) => + message.role === "tool" && + JSON.stringify(message.content).includes(cleared), + ) + assert.ok(clearedResult) + const originalResult = summaries[0].messages.find( + (message) => + message.role === "tool" && message.tool_call_id === clearedResult.tool_call_id, + ) + assert.ok( + originalResult, + "summary must contain the same previously cleared tool result", + ) + assert.ok( + JSON.stringify(originalResult.content).includes(`EVIDENCE_${tag}_`), + "summary receives original output that DCP previously cleared on the ordinary wire", + ) + if (tag === "B") + assert.ok( + history[0].parts.some((part) => part.type === "file"), + "the legal file reference must survive host persistence", + ) + } + } + if (native) { + const session = await api("/session", { title: "Native explicit cancellation" }) + const cancelStarted = Date.now() + const pending = prompt(session.id, "B", "small") + let running = false + for (let attempt = 0; attempt < 100; attempt++) { + const history = await api(`/session/${session.id}/message`) + if (toolParts(history).some((part) => part.state.status === "running")) { + running = true + break + } + await new Promise((resolve) => setTimeout(resolve, 25)) + } + assert.ok(running, "explicit cancellation must target an actually running tool") + await api(`/session/${session.id}/abort`, {}) + await pending + const cancelled = toolParts(await api(`/session/${session.id}/message`)) + assert.equal(cancelled.length, 1) + // The real shell returns a settled result on cancellation. Its null + // exit status and explicit abort note distinguish it from success. + assert.equal(cancelled[0].state.status, "completed") + assert.equal(cancelled[0].state.metadata.exit, null) + assert.ok(cancelled[0].state.output.includes("aborted before completion")) + assert.ok(!cancelled[0].state.output.includes("DCP_CANCEL_MISSED")) + assert.ok( + Date.now() - cancelStarted < 15_000, + "explicit abort must stop the 30-second shell promptly", + ) + assert.equal(emitted.B, 1, "cancelled session must not automatically continue") + metrics.push({ tag: "B", explicitlyCancelled: true }) + } + return metrics +} + +async function baselineScenario() { const a = await api("/session", { title: "DCP public loop A" }) const b = await api("/session", { title: "DCP public loop B" }) process.stdout.write("host: isolated sessions created\n") @@ -351,7 +562,21 @@ try { ), "the persisted native checkpoint must enter the continuation request", ) - process.stdout.write(JSON.stringify({ scenario: "public-host-loop", ok: true }) + "\n") + process.stdout.write(JSON.stringify({ scenario, ok: true }) + "\n") +} + +try { + const { Server } = await import( + pathToFileURL(join(hostRoot, "packages/opencode/src/server/server.ts")).href + ) + listener = await Server.listen({ hostname: "127.0.0.1", port }) + process.stdout.write("host: HTTP listener ready\n") + if (automatic) { + const metrics = await automaticPressure() + process.stdout.write(JSON.stringify({ scenario, ok: true, metrics }) + "\n") + } else { + await baselineScenario() + } } finally { await listener?.stop() modelServer.stop(true)