From c7b898f8d81f5e8698f017c515da9cbe7e5cf358 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Mon, 2 Nov 2026 23:34:37 +0800 Subject: [PATCH 1/3] fix: repair CI runtime, dependency and packaging regressions Split channel tests without losing cases, harden test cleanup and MCP buffering, and update compatible security patches with scoped packaging-source approvals. Align CI and release documentation without weakening signing or platform gates. --- CONTRIBUTING.md | 3 + .../desktop/scripts/build-packaged-daemon.mjs | 47 +- .../packaged-office-native-review-inputs.mjs | 74 +- .../scripts/packaged-office-native-review.mjs | 4 +- apps/desktop/tests/brand-icon-assets.test.mjs | 20 +- apps/desktop/tests/daemon-lifecycle.test.mjs | 12 +- ...n-shutdown-preflight.regression-1.test.mjs | 9 +- ...kaged-codesign-review-publication.test.mjs | 22 +- .../packaged-office-daemon-emitted.test.mjs | 184 +- ...kaged-office-daemon-source-inputs.test.mjs | 33 +- ...ackaged-office-native-publication.test.mjs | 123 +- apps/mobile/package.json | 13 +- .../test/dependency-security-tooling.test.mjs | 167 +- docs/maintenance/ci.md | 16 +- docs/release/desktop.md | 5 +- .../release/macos-source-review-2026-11-02.md | 205 + .../reviewed-packaged-source-v1-review.md | 8 +- .../reviewed-packaged-source-v1.json | 862 +- package.json | 9 +- .../execution-engines/acp-sdk-version.ts | 31 + .../execution-engines/native-admission.ts | 3 +- .../features/mcp/host-stdio-read-buffer.ts | 61 + .../src/features/mcp/host-stdio-transport.ts | 5 +- .../daemon/test/channel-discussion.test.mjs | 4200 +++++ packages/daemon/test/channel-intents.test.mjs | 4678 ++++++ .../daemon/test/channel-workflows.test.mjs | 2754 ++++ .../daemon/test/channel-workspace.test.mjs | 12808 +--------------- .../test/host-text-write-bundle.test.mjs | 8 +- .../test/mcp-host-stdio-transport.test.mjs | 98 +- .../test/recovery-discovery-fixture.test.mjs | 136 + .../daemon/test/runner-acceptance.test.mjs | 49 +- .../support/channel-workspace-fixture.mjs | 1309 ++ .../recovery-discovery-fixture.mjs | 55 +- patches/README.md | 75 +- patches/glob@11.1.0.patch | 96 + patches/glob@13.0.6.patch | 131 + patches/minimatch@10.2.6.patch | 80 + patches/minimatch@9.0.9.patch | 56 + patches/node-forge@1.4.0.patch | 22 - pnpm-lock.yaml | 1387 +- scripts/ci/README.md | 9 +- scripts/ci/test-shard-plan.mjs | 12 +- scripts/ci/test-shard-plan.test.mjs | 13 + scripts/dependency-security-glob.test.mjs | 427 + .../dependency-security-minimatch.test.mjs | 126 + 45 files changed, 16709 insertions(+), 13736 deletions(-) create mode 100644 docs/release/macos-source-review-2026-11-02.md create mode 100644 packages/daemon/src/features/execution-engines/acp-sdk-version.ts create mode 100644 packages/daemon/src/features/mcp/host-stdio-read-buffer.ts create mode 100644 packages/daemon/test/channel-discussion.test.mjs create mode 100644 packages/daemon/test/channel-intents.test.mjs create mode 100644 packages/daemon/test/channel-workflows.test.mjs create mode 100644 packages/daemon/test/recovery-discovery-fixture.test.mjs create mode 100644 packages/daemon/test/support/channel-workspace-fixture.mjs create mode 100644 patches/glob@11.1.0.patch create mode 100644 patches/glob@13.0.6.patch create mode 100644 patches/minimatch@10.2.6.patch create mode 100644 patches/minimatch@9.0.9.patch delete mode 100644 patches/node-forge@1.4.0.patch create mode 100644 scripts/dependency-security-glob.test.mjs create mode 100644 scripts/dependency-security-minimatch.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f4e76f63..ff409c82e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,9 @@ only when its description is complete and its claimed validation has actually ru ## Validation +See [CI maintenance](docs/maintenance/ci.md) for the current workflow, test +execution and dependency-patch maintenance guides. + For documentation-only changes: ```bash diff --git a/apps/desktop/scripts/build-packaged-daemon.mjs b/apps/desktop/scripts/build-packaged-daemon.mjs index 2bcaaac09..de089cfcb 100644 --- a/apps/desktop/scripts/build-packaged-daemon.mjs +++ b/apps/desktop/scripts/build-packaged-daemon.mjs @@ -173,12 +173,35 @@ async function bundleOriginalDaemon(prepareOriginals) { finally { bundling = false; } } +function readBundledAcpSdkManifest(entry) { + let directory = dirname(entry); + while (true) { + const path = join(directory, "package.json"); + let contents, manifest; + try { contents = readFileSync(path, "utf8"); manifest = JSON.parse(contents); } + catch (error) { if (error.code !== "ENOENT") throw error; } + if (manifest?.name === "@agentclientprotocol/sdk") { + if (typeof manifest.version !== "string" || manifest.version.trim().length === 0) { + throw new Error("packaged_acp_sdk_version_manifest_invalid"); + } + return { path, contents, version: manifest.version }; + } + const parent = dirname(directory); + if (parent === directory) throw new Error("packaged_acp_sdk_version_manifest_missing"); + directory = parent; + } +} + async function buildOriginalDaemonBundle(codesignReceipt, officeReceipt, sourceInputs) { requireBundle(Boolean(officeReceipt) === Boolean(sourceInputs)); const gstackIdentity = await preparePackagedGstackBuild({ required: Boolean(officeReceipt) && process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED === "1", }); const gstack = packagedGstackIdentityPlugin({ identity: gstackIdentity, sourceInputs }); + const acpVersionModule = realpathSync(resolve(desktopRoot, "../../packages/daemon", + sourceInputs ? "src/features/execution-engines/acp-sdk-version.ts" : "dist/features/execution-engines/acp-sdk-version.js")); + const acpVersionMarker = `const embeddedBundledAcpSdkVersion${sourceInputs ? ": string" : ""} = "NO_PACKAGED_ACP_SDK_VERSION";`; + let acpVersionVisits = 0, acpManifest; const definitions = [ { name: "codesign", suffix: /packaged-codesign-published-review\.js$/, @@ -205,7 +228,27 @@ async function buildOriginalDaemonBundle(codesignReceipt, officeReceipt, sourceI bundle: true, platform: "node", format: "esm", outfile: "dist/daemon/cli.mjs", ...(sourceInputs ? { write: false, metafile: true, tsconfigRaw: sourceInputs.tsconfigRaw, target: "es2022" } : {}), banner: { js: 'import { createRequire as __lingCreateRequire } from "node:module"; const require = __lingCreateRequire(import.meta.url);' }, - plugins: [gstack.plugin, ...definitions.map(item => ({ + plugins: [gstack.plugin, { + name: "build-owned-acp-sdk-version-literal", + setup(plugin) { + plugin.onLoad({ filter: /features[\\/]execution-engines[\\/]acp-sdk-version\.(?:ts|js)$/ }, async args => { + if (realpathSync(args.path) !== acpVersionModule) throw new Error("packaged_acp_sdk_version_module_changed"); + if (++acpVersionVisits !== 1) throw new Error("packaged_acp_sdk_version_module_reloaded"); + const source = sourceInputs ? sourceInputs.loadSource(acpVersionModule).contents : readFileSync(acpVersionModule, "utf8"); + if (source.split(acpVersionMarker).length !== 2) throw new Error("packaged_acp_sdk_version_marker_changed"); + const sdk = await plugin.resolve("@agentclientprotocol/sdk", { + resolveDir: resolve(desktopRoot, "../../packages/daemon"), kind: "import-statement", + }); + if (sdk.errors.length || sdk.external || sdk.namespace !== "file" || !sdk.path) { + throw new Error("packaged_acp_sdk_version_resolution_failed"); + } + acpManifest = readBundledAcpSdkManifest(realpathSync(sdk.path)); + return { contents: source.replace(acpVersionMarker, + `const embeddedBundledAcpSdkVersion${sourceInputs ? ": string" : ""} = ${JSON.stringify(acpManifest.version)};`), + loader: sourceInputs ? "ts" : "js", resolveDir: dirname(acpVersionModule) }; + }); + }, + }, ...definitions.map(item => ({ name: `original-${item.name}-review-literal`, setup(plugin) { plugin.onLoad({ filter: sourceInputs ? new RegExp(item.suffix.source.replace("\\.js", "\\.ts")) : item.suffix }, args => { @@ -221,6 +264,8 @@ async function buildOriginalDaemonBundle(codesignReceipt, officeReceipt, sourceI })), ...(sourceInputs ? [sourceInputs.plugin] : [])], }); const originalOutput = officeReceipt ? pinOriginalBuildOutput(result) : undefined; + if (acpVersionVisits !== 1) throw new Error("packaged_acp_sdk_version_not_bundled"); + if (readFileSync(acpManifest.path, "utf8") !== acpManifest.contents) throw new Error("packaged_acp_sdk_version_manifest_changed"); gstack.assertIncluded(); sourceInputs?.assertOriginalInputs(result); for (const item of definitions) { diff --git a/apps/desktop/scripts/packaged-office-native-review-inputs.mjs b/apps/desktop/scripts/packaged-office-native-review-inputs.mjs index d2095bb0f..cce9b93b9 100644 --- a/apps/desktop/scripts/packaged-office-native-review-inputs.mjs +++ b/apps/desktop/scripts/packaged-office-native-review-inputs.mjs @@ -19,6 +19,9 @@ * resolved in this fixed audit. It is not proof about nonliteral/user-selected * imports, third-party vendor source, Rust/ * linker/native-publisher tool identities, the complete SDK or OS/kernel trust. + * Optional LanceDB imports remain external opt-ins, disabled by default. Their + * authored loader is pinned; configured provider/vendor/native bytes are not + * part of this source approval. * The fixed builder config is an explicit root for its nonliteral config URL. * Provider=true source-build verifier and Windows custom signer are not roots: * this fixed mac/provider=false lane rejects provider=true before packaging. @@ -33,15 +36,15 @@ * generated files. Source config changes require a new explicit review. * Human QA's early-entry/lifecycle sources are explicit non-Office-build review * inputs: their inclusion pins QA isolation/startup, not the entire Main graph. - * - * Local service IPC is an explicit root. Its Main, preload and Web wiring are - * exact nonrecursive inputs; pinning Main does not include its whole graph. + * Local-service IPC is an authored root; its Main/preload/Web wiring is pinned + * nonrecursively, without pulling the entire Main graph into this closure. * * A graph change requires an explicit review of this fixed list. It must not * silently discover, regenerate, approve, or refresh digests during a build. */ export const PACKAGED_OFFICE_NATIVE_AUTHORED_REVIEW_ROOTS = Object.freeze([ "apps/desktop/main/resource-paths.ts", + "apps/desktop/main/local-services.ts", "apps/desktop/scripts/after-sign.mjs", "apps/desktop/scripts/engine-supervisor-package.mjs", "apps/desktop/scripts/build-packaged-daemon.mjs", @@ -83,20 +86,9 @@ export const PACKAGED_OFFICE_NATIVE_AUTHORED_REVIEW_ROOTS = Object.freeze([ "packages/daemon/src/runtime/sandbox/packaged-office-native-published-review.ts", "packages/daemon/src/runtime/sandbox/packaged-office-native-resources.ts", "packages/daemon/src/runtime/sandbox/protected-host-office-native-identity.ts", - "apps/desktop/main/local-services.ts", ]); export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ - // Restore the exact local-service IPC inputs dropped during main integration. - // Listing these sources does not approve their bytes or root the Main graph. - "apps/desktop/main/local-services.ts", - "apps/desktop/main/main.ts", - "apps/desktop/main/ipc.ts", - "apps/desktop/preload/main-window.ts", - "apps/desktop/shared/ipc-channels.ts", - "apps/desktop/shared/desktop-capabilities.ts", - "apps/desktop/shared/local-service-capabilities.ts", - "apps/web/src/api/client.ts", // Shared HTTP/upgrade authentication; this entry does not approve new bytes. "packages/daemon/src/server/request-auth.ts", // Explicit managed-engine runtime closure; listing alone grants no approval. @@ -125,6 +117,47 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/channels/channel-pending-operation.ts", // Host-issued channel dependency scope; listing alone grants no approval. "packages/daemon/src/features/channels/channel-command-input-policy.ts", + // Explicit reviewed session, learning and memory closure; no build-time discovery. + "packages/contracts/src/channel-sessions-validation.ts", + "packages/contracts/src/learning-reviews.ts", + "packages/contracts/src/memory-derivatives.ts", + "packages/contracts/src/memory-wiki.ts", + "packages/core/src/channel-session-types.ts", + "packages/core/src/learning-review-types.ts", + "packages/core/src/memory-derivative-types.ts", + "packages/core/src/memory-wiki-types.ts", + "packages/core/src/storage/sqlite/channel-session-admission.ts", + "packages/core/src/storage/sqlite/channel-session-lineage.ts", + "packages/core/src/storage/sqlite/channel-session-migration.ts", + "packages/core/src/storage/sqlite/channel-session-notification.ts", + "packages/core/src/storage/sqlite/channel-session-store.ts", + "packages/core/src/storage/sqlite/learning-review-migration.ts", + "packages/core/src/storage/sqlite/learning-review-store.ts", + "packages/core/src/storage/sqlite/memory-derivative-migration.ts", + "packages/core/src/storage/sqlite/memory-derivative-store.ts", + "packages/core/src/storage/sqlite/memory-search-index.ts", + "packages/core/src/storage/sqlite/memory-wiki-store.ts", + "packages/core/src/storage/sqlite/procedure-observation-job-migration.ts", + "packages/core/src/storage/sqlite/skill-definition-revision-migration.ts", + "packages/core/src/storage/sqlite/skill-definition-revision-store.ts", + "packages/daemon/src/features/channels/conversation-runtime-metrics-scope.ts", + "packages/daemon/src/features/channels/session-routes.ts", + "packages/daemon/src/features/learning/coordinator.ts", + "packages/daemon/src/features/learning/evidence.ts", + "packages/daemon/src/features/learning/extractor.ts", + "packages/daemon/src/features/learning/model.ts", + "packages/daemon/src/features/learning/routes.ts", + "packages/daemon/src/features/learning/scoped-candidates.ts", + "packages/daemon/src/features/memory/embedding-client.ts", + "packages/daemon/src/features/memory/lancedb-provider.ts", + "packages/daemon/src/features/memory/recall-service.ts", + "packages/daemon/src/features/memory/semantic-registry.ts", + "packages/daemon/src/features/memory/semantic-routes.ts", + "packages/daemon/src/features/memory/semantic-service.ts", + "packages/daemon/src/features/memory/wiki-compiler.ts", + "packages/daemon/src/features/memory/wiki-routes.ts", + "packages/daemon/src/features/skills/procedure-observation-service.ts", + "packages/daemon/src/features/skills/revision-notices.ts", // FD5 observer and its CLI signal owner are explicit, independently reviewed inputs. "packages/core/src/daemon-shutdown-observer.ts", "packages/daemon/src/runtime/signal-shutdown.ts", @@ -144,6 +177,15 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/conversations/context-compaction-projection.ts", // Explicit cold resource-bootstrap delta; listing alone grants no approval. "apps/desktop/main/daemon-lifecycle.ts", + // Fixed local-service IPC and its exact integration inputs; listing is not approval. + "apps/desktop/main/local-services.ts", + "apps/desktop/main/main.ts", + "apps/desktop/main/ipc.ts", + "apps/desktop/preload/main-window.ts", + "apps/desktop/shared/ipc-channels.ts", + "apps/desktop/shared/desktop-capabilities.ts", + "apps/desktop/shared/local-service-capabilities.ts", + "apps/web/src/api/client.ts", // Explicit shutdown/lease-consumer delta, independently reviewed before binding. "apps/desktop/main/desktop-runtime.ts", "packages/core/src/daemon-resource-bootstrap.ts", @@ -1044,6 +1086,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/harness/tool-execution/recovery.ts", "packages/daemon/src/features/mcp/broker.ts", "packages/daemon/src/features/mcp/host-operation.ts", + "packages/daemon/src/features/mcp/host-stdio-read-buffer.ts", "packages/daemon/src/features/mcp/host-stdio-transport.ts", "packages/daemon/src/features/mcp/office-local-mcp-operation.ts", "packages/daemon/src/features/mcp/reserved-ids.ts", @@ -1676,6 +1719,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "packages/daemon/src/features/execution-engines/kimi-connection.ts", "packages/daemon/src/features/execution-engines/kimi-native-executable.ts", "packages/daemon/src/features/execution-engines/kimi-probe-isolation.ts", + "packages/daemon/src/features/execution-engines/acp-sdk-version.ts", "packages/daemon/src/features/execution-engines/native-admission.ts", "packages/daemon/src/features/execution-engines/native-auth-home.ts", "packages/daemon/src/features/execution-engines/native-login.ts", @@ -1758,7 +1802,7 @@ export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.freeze([ "scripts/gstack/testsavant-model-cache.mjs", "scripts/gstack/testsavant-model-pin.json", // Explicitly reviewed mainline runtime edges, 2026-10-29. Keep existing inputs - // and the 41 authored roots; packaging never discovers or approves this list. + // and the fixed authored roots; packaging never discovers or approves this list. "packages/contracts/src/deliverables.ts", "packages/core/src/runner-ids.ts", "packages/core/src/storage/sqlite/runner-id-migration.ts", diff --git a/apps/desktop/scripts/packaged-office-native-review.mjs b/apps/desktop/scripts/packaged-office-native-review.mjs index 0a7338423..ab3b691ec 100644 --- a/apps/desktop/scripts/packaged-office-native-review.mjs +++ b/apps/desktop/scripts/packaged-office-native-review.mjs @@ -136,7 +136,9 @@ export function readLocalPackagedOfficeSource() { })); requireReview(OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS.every(name => sourceSha256[name] === approved.sourceSha256[name])); - const observed = observeOfficeNativeToolchain(); + const observed = observeOfficeNativeToolchain({ + profiles: approved.toolchains, headerDigests: approved.headers.sha256, + }); exactKeys(observed.headers.sha256, headerNames); requireReview(headerNames.every(name => validHash(observed.headers.sha256[name]))); const review = freezeReview({ ...approved, sourceSha256, diff --git a/apps/desktop/tests/brand-icon-assets.test.mjs b/apps/desktop/tests/brand-icon-assets.test.mjs index 6a8653f48..abefbd570 100644 --- a/apps/desktop/tests/brand-icon-assets.test.mjs +++ b/apps/desktop/tests/brand-icon-assets.test.mjs @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; import { createCanvas, loadImage } from "@napi-rs/canvas"; -import { brandRoot, macSlots, pngSizes, renderBrandIcon, repoRoot } from "../scripts/brand-icon-assets.mjs"; +import { brandRoot, encodeIcns, macSlots, pngSizes, renderBrandIcon, repoRoot } from "../scripts/brand-icon-assets.mjs"; import { inspectIcoFrames } from "./verify-windows-app-icons.mjs"; function pixels(canvas) { @@ -122,13 +122,23 @@ test("macOS system decoder preserves all standard and Retina optical representat t.after(() => rm(directory, { recursive: true, force: true })); const output = resolve(directory, "Ling.iconset"); execFileSync("iconutil", ["-c", "iconset", resolve(repoRoot, "apps/desktop/build/icon.icns"), "-o", output]); - for (const slot of macSlots) { + const frames = []; + for (const slot of macSlots) frames.push({ slot, ...await renderBrandIcon(slot.size, slot.logical) }); + const currentIcns = resolve(directory, "current.icns"); + const currentOutput = resolve(directory, "Current.iconset"); + await writeFile(currentIcns, encodeIcns(frames)); + execFileSync("iconutil", ["-c", "iconset", currentIcns, "-o", currentOutput]); + for (const { slot, canvas: expected } of frames) { const actual = await decode(resolve(output, `icon_${slot.name}.png`), slot.size); - const expected = (await renderBrandIcon(slot.size, slot.logical)).canvas; assertSameArtwork(actual, expected); if (slot.size >= 256) assertCrispExteriorEdges(actual); + // Historical assets allow renderer drift; exact decoder fidelity uses the + // same raster that was encoded, without depending on a previous canvas build. + const roundtrip = await decode(resolve(currentOutput, `icon_${slot.name}.png`), slot.size); + assertSameArtwork(roundtrip, expected); + if (slot.size >= 256) assertCrispExteriorEdges(roundtrip); if (slot.argb) { - const a = pixels(actual); + const a = pixels(roundtrip); const b = pixels(expected); for (let pixel = 0; pixel < a.length; pixel += 4) { assert.equal(a[pixel + 3], b[pixel + 3]); diff --git a/apps/desktop/tests/daemon-lifecycle.test.mjs b/apps/desktop/tests/daemon-lifecycle.test.mjs index c7b78165a..362ef5643 100644 --- a/apps/desktop/tests/daemon-lifecycle.test.mjs +++ b/apps/desktop/tests/daemon-lifecycle.test.mjs @@ -169,7 +169,8 @@ test("packaged validation may take 52 seconds before readiness and engine author t.mock.timers.enable({ apis: ["Date", "setTimeout"] }); const child = createFakeChild(); const channel = new PassThrough(); - child.stdio = [null, null, null, channel]; + const resources = new PassThrough(); + child.stdio = [null, null, null, channel, resources]; const spawned = deferred(); const readyAt = Date.now() + 52_000; const lifecycle = new DesktopDaemonLifecycle({ @@ -193,6 +194,7 @@ test("packaged validation may take 52 seconds before readiness and engine author assert.equal(channel.destroyed, false, "FD3 must survive the same packaged validation budget as readiness"); await assert.rejects(lifecycle.getExecutionEngineAuthority(), /engine_host_authority_unavailable/); t.mock.timers.tick(31_000); + resources.end(JSON.stringify({ schemaVersion: 1, phase: "resources_ready", pid: child.pid })); channel.end(JSON.stringify(engineAuthorityFixture(child.pid))); await new Promise(resolve => setImmediate(resolve)); assert.equal((await startup).ownedByDesktop, true); @@ -227,6 +229,7 @@ for (const [label, options, budget] of [ const child = createFakeChild(); const channel = new PassThrough(); child.stdio = [null, null, null, channel]; + acknowledgePackagedResources(child); const spawned = deferred(); const lifecycle = new DesktopDaemonLifecycle({ baseUrl: "http://127.0.0.1:17991", daemonEntryPath: existingEntryPath, @@ -289,7 +292,14 @@ function engineAuthorityFixture(pid) { credentials: { capability: "fixture".repeat(7), csrfToken: "csrf".repeat(11) } }; } +function acknowledgePackagedResources(child) { + const resources = new PassThrough(); + child.stdio[4] = resources; + resources.end(JSON.stringify({ schemaVersion: 1, phase: "resources_ready", pid: child.pid })); +} + function engineLifecycle(child, onSpawn = () => {}, options = {}) { + if (options.packaged) acknowledgePackagedResources(child); let probes = 0; return new DesktopDaemonLifecycle({ baseUrl: "http://127.0.0.1:17991", daemonEntryPath: existingEntryPath, diff --git a/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs b/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs index af1290319..28fff64a5 100644 --- a/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs +++ b/apps/desktop/tests/main-shutdown-preflight.regression-1.test.mjs @@ -22,6 +22,7 @@ for (const code of ["stop_blocked", "stop_unconfirmed"]) { assert.equal(f.context.desktopRuntime, f.runtime); assert.equal(f.context.computerUse, f.computerUse); assert.equal(f.context.officeDriverRuntime, f.office); + assert.equal(f.context.backgroundRepository, f.background); }); } @@ -35,9 +36,13 @@ test("main does not destroy any owned transport before Host preflight resolves", release(); await stopping; assert.ok(f.events.indexOf("computer-use") > f.events.indexOf("host-preflight")); + assert.ok(f.events.indexOf("background-ipc") > f.events.indexOf("host-preflight")); + assert.ok(f.events.indexOf("background") > f.events.indexOf("background-ipc")); assert.ok(f.events.indexOf("runtime-cleanup") > f.events.indexOf("office")); assert.equal(f.context.mainWindow, undefined); assert.equal(f.context.desktopRuntime, undefined); + assert.equal(f.context.unregisterBackgroundIpc, undefined); + assert.equal(f.context.backgroundRepository, undefined); }); function fixture(prepare) { @@ -48,17 +53,19 @@ function fixture(prepare) { async shutdown() { events.push("runtime-cleanup"); } }; const computerUse = { async dispose() { events.push("computer-use"); } }; const office = { async stop() { events.push("office"); } }; + const background = { dispose() { events.push("background"); } }; const context = vm.createContext({ mainWindowOpenCoordinator: { stopAcceptingAndWait() { events.push("stop-opens"); return Promise.resolve(); } }, mainWindow: window, loadingMainWindow: undefined, desktopRuntime: runtime, computerUse, officeDriverRuntime: office, hostCapabilityRuntime: { async stop() { events.push("host-capabilities"); } }, unregisterLockedControlPreview() { events.push("preview-ipc"); }, windowsProviderSmokeCommandReader: undefined, macosTccRevocationCommandReader: undefined, + unregisterBackgroundIpc() { events.push("background-ipc"); }, backgroundRepository: background, unregisterThemePackageIpc() { events.push("theme-ipc"); }, humanEvaluation: { async dispose() { events.push("human-evaluation"); } }, unregisterIpc() { events.push("ipc"); }, captureSmokeTargetWindows: [], realCaptureSmokeDescriptorRegistry: undefined, realCaptureSmokeProjectionClient: undefined, }); const shutdown = vm.runInContext(`${compiled.code}\nshutdown;`, context); - return { context, shutdown, events, window, runtime, computerUse, office }; + return { context, shutdown, events, window, runtime, computerUse, office, background }; } diff --git a/apps/desktop/tests/packaged-codesign-review-publication.test.mjs b/apps/desktop/tests/packaged-codesign-review-publication.test.mjs index 5d5c3f5b7..d449ccaff 100644 --- a/apps/desktop/tests/packaged-codesign-review-publication.test.mjs +++ b/apps/desktop/tests/packaged-codesign-review-publication.test.mjs @@ -7,16 +7,34 @@ import { createHash } from "node:crypto"; import { registerHooks, syncBuiltinESMExports } from "node:module"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { readReviewedCodesignSource } from "../scripts/packaged-codesign-review-publication.mjs"; const desktopRoot = realpathSync(fileURLToPath(new URL("../", import.meta.url))); const nativeRoot = resolve(desktopRoot, "../../native/sandbox"); const hash = bytes => createHash("sha256").update(bytes).digest("hex"); +test("committed codesign review has distinct OS identities and matches the existing Office review pin", () => { + const { review, sourceReviewSha256 } = readReviewedCodesignSource(); + const releases = [review.os, ...review.additionalOs].map(row => row.release); + assert.equal(new Set(releases).size, releases.length); + const office = JSON.parse(readFileSync(resolve(nativeRoot, "../office-local/reviewed-packaged-source-v1.json"), "utf8")); + assert.equal(sourceReviewSha256, office.packagingSha256["native/sandbox/reviewed-codesign-source-v1.json"]); +}); +function syntheticReview() { + const review = JSON.parse(readFileSync(join(nativeRoot, "reviewed-codesign-source-v1.json"), "utf8")); + const rows = ["24.0.0", "25.0.0"].map(release => ({ + release, backendHelperVersion: `ling-seatbelt-v1:${release}:${hash("SYNTHETIC BACKEND VERSION:" + release)}`, + backendSha256: hash("SYNTHETIC BACKEND:" + release), codesignSha256: hash("SYNTHETIC CODESIGN:" + release), + templateSha256: hash("SYNTHETIC TEMPLATE:" + release), taskgatedBinding: "reviewed_xml_v1", + })); + // Publication mechanics use disposable OS identities, never release approvals. + return { ...review, schemaVersion: 2, os: rows[0], additionalOs: rows.slice(1) }; +} async function fixture(t, { hostRelease } = {}) { const root = realpathSync(mkdtempSync("/private/tmp/ling-codesign-publication-test-")); t.after(() => rmSync(root, { recursive: true, force: true })); const scripts = join(root, "apps/desktop/scripts"), dist = join(root, "apps/desktop/dist"); mkdirSync(scripts, { recursive: true }); mkdirSync(dist); - const review = JSON.parse(readFileSync(join(nativeRoot, "reviewed-codesign-source-v1.json"), "utf8")); + const review = syntheticReview(); const releaseMock = t.mock.method(os, "release", () => hostRelease ?? review.os.release); t.after(() => { releaseMock.mock.restore(); syncBuiltinESMExports(); }); const fixtureNative = join(root, "native/sandbox"); mkdirSync(fixtureNative, { recursive: true }); @@ -114,7 +132,7 @@ test("same-process original trusted build receipt (explicit synthetic builder, n assert.equal(f.counts().signingCalls, 0); }); await t.test("each exact reviewed OS builds a descriptor bound to just that tuple", async t => { - const current = JSON.parse(readFileSync(join(nativeRoot, "reviewed-codesign-source-v1.json"), "utf8")); + const current = syntheticReview(); for (const selected of [current.os, ...current.additionalOs]) { await t.test(selected.release, async t => { const f = await fixture(t, { hostRelease: selected.release }); diff --git a/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs b/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs index 96c114cee..54a615d5c 100644 --- a/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs +++ b/apps/desktop/tests/packaged-office-daemon-emitted.test.mjs @@ -1,13 +1,14 @@ import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import fsModule from "node:fs"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { registerHooks, syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { build } from "esbuild"; +import { build, transform } from "esbuild"; const script = fileURLToPath(new URL("../scripts/build-packaged-daemon.mjs", import.meta.url)); const sourceScript = fileURLToPath(new URL("../scripts/packaged-office-daemon-source-inputs.mjs", import.meta.url)); @@ -16,7 +17,7 @@ let fixtureNumber = 0; function newFile(path, bytes) { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); writeFileSync(path, bytes, { mode: 0o600 }); } /** Real esbuild/source projection; native/codesign origin, review and external gstack selection are explicitly synthetic. */ -async function fixture(t) { +async function fixture(t, options = {}) { const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-office-emitted-test-"))); t.after(() => rmSync(root, { recursive: true, force: true })); const modulePath = join(root, "apps/desktop/scripts/build-packaged-daemon.mjs"); @@ -34,18 +35,37 @@ async function fixture(t) { newFile(officeModule, officeMarker + "\nexport const office = embeddedPublishedOfficeReviewJson;\n"); newFile(join(runtime, "packaged-codesign-published-review.js"), readFileSync(codesignModule, "utf8").replace(": string", "")); newFile(join(runtime, "packaged-office-native-published-review.js"), readFileSync(officeModule, "utf8").replace(": string", "")); + const acpModule = join(root, "packages/daemon/src/features/execution-engines/acp-sdk-version.ts"); + const originalAcpSource = readFileSync(new URL("../../../packages/daemon/src/features/execution-engines/acp-sdk-version.ts", import.meta.url), "utf8"); + const acpSource = options.acpSource?.(originalAcpSource) ?? originalAcpSource; + newFile(acpModule, acpSource); + newFile(join(root, "packages/daemon/dist/features/execution-engines/acp-sdk-version.js"), + (await transform(acpSource, { loader: "ts", target: "es2022" })).code); + const sdkRoot = join(root, "node_modules/@agentclientprotocol/sdk"); + if (options.sdkDirectory) { + cpSync(options.sdkDirectory, sdkRoot, { recursive: true }); + symlinkSync(realpathSync(new URL("../../../packages/daemon/node_modules/zod", import.meta.url)), join(root, "node_modules/zod"), "dir"); + } else { + newFile(join(sdkRoot, "package.json"), JSON.stringify({ name: "@agentclientprotocol/sdk", + version: options.version ?? "0.0.0-fixture", type: "module", exports: "./" + (options.layout ?? "dist/acp.js") })); + newFile(join(sdkRoot, options.layout ?? "dist/acp.js"), "export const PROTOCOL_VERSION = 1;\n"); + } + const sdkManifest = join(sdkRoot, "package.json"); const entry = join(root, "packages/daemon/dist/cli.js"); newFile(entry, 'export { codesign } from "./runtime/sandbox/packaged-codesign-published-review.js";\n' - + 'export { office } from "./runtime/sandbox/packaged-office-native-published-review.js";\n'); + + 'export { office } from "./runtime/sandbox/packaged-office-native-published-review.js";\n' + + 'export { readInstalledAcpSdkVersion } from "./features/execution-engines/acp-sdk-version.js";\n' + + 'export { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";\n'); const sourceEntry = join(root, "packages/daemon/src/cli.ts"); newFile(sourceEntry, readFileSync(entry)); - const sourceFiles = [sourceEntry, codesignModule, officeModule]; + const sourceFiles = [sourceEntry, codesignModule, officeModule, acpModule]; for (const name of ["browser-host", "contracts", "core", "daemon", "localization", "plugin-sdk", "providers", "remote-protocol", "runner-pi"]) { const packagePath = join(root, "packages", name, "package.json"), configPath = join(root, "packages", name, "tsconfig.json"); newFile(packagePath, JSON.stringify({ name: "@ling-stack/" + name, type: "module", main: "./dist/index.js" })); newFile(configPath, JSON.stringify({ compilerOptions: { module: "NodeNext", moduleResolution: "NodeNext", target: "ES2022", outDir: "dist", rootDir: "src", strict: true }, include: ["src/**/*.ts"] })); - sourceFiles.push(packagePath, configPath); + const index = join(root, "packages", name, "src/index.ts"); newFile(index, "export {};\n"); + sourceFiles.push(packagePath, configPath, index); } const sourceReviewSha256 = hash("EXPLICIT SYNTHETIC REVIEW (not production approval)"); const sourceReview = Object.freeze({ sourceReviewSha256, review: Object.freeze({ packagingSha256: Object.freeze( @@ -56,6 +76,7 @@ async function fixture(t) { const codesignToken = Object.freeze({ synthetic: "codesign token" }); const codesign = Object.freeze({ synthetic: "codesign publication (not approval)" }); const state = { active: publication, original: publication, token, publication, codesignToken, codesign, sourceReview, + mode: options.mode, nativeBuilds: 0, codesignBuilds: 0, buildCompleted: false, builds: [], outputs: [], afterBuild: undefined, afterOriginalRead: undefined, gstackPreparations: [], readOriginal(receipt) { @@ -64,6 +85,7 @@ async function fixture(t) { }, async build(options) { state.builds.push(options); + state.beforeBuild?.(options); const result = await build({ ...options, logLevel: "silent" }); state.outputs.push(result.outputFiles?.map(file => Buffer.from(file.contents))); state.buildCompleted = true; state.afterBuild?.(result); return result; @@ -86,7 +108,12 @@ async function fixture(t) { }`], ["./packaged-office-native-review.mjs", bridge + ` export function readReviewedPackagedOfficeSource() { return state.sourceReview; } - export function readLocalPackagedOfficeSource() { throw new Error("synthetic_review_requires_release_path"); }`], + export function readLocalPackagedOfficeSource() { + if (state.mode !== "source") throw new Error("synthetic_review_requires_release_path"); + return state.sourceReview; + }`], + ["./packaged-office-native-review-inputs.mjs", bridge + ` + export const PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS = Object.keys(state.sourceReview.review.packagingSha256);`], ["./packaged-gstack-resources.mjs", bridge + ` export async function preparePackagedGstackBuild(options) { state.gstackPreparations.push(options); return undefined; } export function packagedGstackIdentityPlugin({ identity }) { @@ -106,15 +133,18 @@ async function fixture(t) { Object.defineProperty(process, name, { ...original, value }); t.after(() => Object.defineProperty(process, name, original)); } - const priorReleaseIntent = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; - t.after(() => { if (priorReleaseIntent === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = priorReleaseIntent; }); const priorIntent = process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED; delete process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED; t.after(() => { if (priorIntent === undefined) delete process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED; else process.env.LING_PACKAGED_CODESIGN_REVIEW_REQUIRED = priorIntent; }); - return { root, api: await import(url), state, publication, token, entry: sourceEntry, cliPath, officeModule, officeMarker, codesignModule, codesignMarker }; + const priorRelease = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + if (options.mode === "source") delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; + t.after(() => { if (priorRelease === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = priorRelease; }); + return { root, api: await import(url), state, publication, token, entry: sourceEntry, distEntry: entry, cliPath, + acpModule, acpDist: join(root, "packages/daemon/dist/features/execution-engines/acp-sdk-version.js"), sdkManifest, + officeModule, officeMarker, codesignModule, codesignMarker }; } test("Office emitted CLI pins: real esbuild, synthetic publication/signature, not macOS package acceptance", async t => { @@ -273,3 +303,133 @@ test("Office emitted CLI pins: real esbuild, synthetic publication/signature, no }); t.diagnostic("Real esbuild/source projection and disposable inputs only; synthetic source review, original native/codesign readers and external gstack selection/identity. These output custody contracts do not replace the real source-review integration, gstack resource/identity validation, real signatures, Electron, ZIP/DMG, or packaged acceptance."); }); + +function runVersion(modulePath, cwd, entryUrl) { + const code = `import { readInstalledAcpSdkVersion } from ${JSON.stringify(pathToFileURL(modulePath).href)}; + console.log(readInstalledAcpSdkVersion(${entryUrl === undefined ? "" : JSON.stringify(entryUrl)}));`; + return spawnSync(process.execPath, ["--input-type=module", "-e", code, "--", "999.0.0-forged"], { + cwd, encoding: "utf8", timeout: 20_000, maxBuffer: 1024 * 1024, + env: { PATH: process.env.PATH, HOME: cwd, LING_ACP_SDK_VERSION: "999.0.0-forged", ACP_SDK_VERSION: "999.0.0-forged" }, + }); +} + +async function bundleVersionFixture(f, mode) { + if (mode === "dist") await f.api.buildPackagedDaemon(); + else await f.api.buildFreshPackagedDaemonForMacOffice(); +} + +function assertIsolatedVersion(t, f, expected) { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "ling-acp-no-sdk-"))); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const module = join(directory, "cli.mjs"); copyFileSync(f.cliPath, module); + // A caller-selected entry and environment must not override the build-owned version. + const foreignEntry = join(directory, "foreign/dist/acp.js"); + newFile(join(directory, "foreign/package.json"), JSON.stringify({ name: "@agentclientprotocol/sdk", version: "999.0.0-forged" })); + for (const entryUrl of [undefined, pathToFileURL(foreignEntry).href]) { + const result = runVersion(module, directory, entryUrl); + assert.equal(result.error, undefined); assert.equal(result.signal, null); + assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout.trim(), expected); + } + const output = readFileSync(module, "utf8"); + assert.doesNotMatch(output, /from ["']@agentclientprotocol\/sdk["']/u); + assert.match(output, /PROTOCOL_VERSION/u); +} + +test("ACP SDK binding: dist, local source and reviewed source use real esbuild and isolated Node", async t => { + for (const mode of ["dist", "source", "reviewed"]) { + for (const [version, layout] of [["1.9.0", "dist/acp.js"], ["2.1.0", "dist/v1/acp.js"]]) { + await t.test(`${mode}: synthetic ${version} layout, no runtime node_modules`, async t => { + const f = await fixture(t, { mode, version, layout }); + // Resolution must originate from daemon, never Desktop's own installation. + newFile(join(f.root, "apps/desktop/node_modules/@agentclientprotocol/sdk/package.json"), JSON.stringify({ + name: "@agentclientprotocol/sdk", version: "999.0.0-desktop-decoy", exports: "./acp.js", + })); + newFile(join(f.root, "apps/desktop/node_modules/@agentclientprotocol/sdk/acp.js"), "export const PROTOCOL_VERSION = 999;\n"); + const source = runVersion(f.acpDist, f.root); + assert.equal(source.status, 0, source.stderr); assert.equal(source.stdout.trim(), version); + await bundleVersionFixture(f, mode); + assertIsolatedVersion(t, f, version); + }); + } + } +}); + +test("ACP SDK binding: actual installed SDK bytes survive all packaging input modes", async t => { + const directories = process.env.LING_TEST_ACP_SDK_DIRECTORIES + ? JSON.parse(process.env.LING_TEST_ACP_SDK_DIRECTORIES) + : [fileURLToPath(new URL("../../../packages/daemon/node_modules/@agentclientprotocol/sdk", import.meta.url))]; + for (const directory of directories) { + const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + assert.equal(manifest.name, "@agentclientprotocol/sdk"); + for (const mode of ["dist", "source", "reviewed"]) await t.test(`${mode}: real SDK ${manifest.version}`, async t => { + const f = await fixture(t, { mode, sdkDirectory: directory }); + const source = runVersion(f.acpDist, f.root); + assert.equal(source.status, 0, source.stderr); assert.equal(source.stdout.trim(), manifest.version); + await bundleVersionFixture(f, mode); assertIsolatedVersion(t, f, manifest.version); + }); + } +}); + +test("ACP SDK binding rejects changed/missing/duplicate markers in compiled and captured inputs", async t => { + const marker = 'const embeddedBundledAcpSdkVersion: string = "NO_PACKAGED_ACP_SDK_VERSION";'; + for (const mode of ["dist", "source", "reviewed"]) for (const kind of ["missing", "duplicate"]) { + await t.test(`${mode}: ${kind} marker`, async t => { + const f = await fixture(t, { mode, acpSource(source) { + return kind === "missing" ? source.replace(marker, marker.replace("NO_PACKAGED_ACP_SDK_VERSION", "CHANGED_MARKER")) + : source + "\n// " + marker + "\n"; + } }); + // esbuild removes the duplicate comment when emitting JS; keep the literal-count probe explicit. + if (mode === "dist" && kind === "duplicate") { + writeFileSync(f.acpDist, readFileSync(f.acpDist, "utf8") + "\n// " + marker.replace(": string", "") + "\n"); + } + await assert.rejects(bundleVersionFixture(f, mode), /packaged_acp_sdk_version_marker_changed/u); + }); + } + await t.test("unvisited helper cannot silently leave the runtime resolver in a package", async t => { + const f = await fixture(t); + writeFileSync(f.distEntry, 'export { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";\n'); + await assert.rejects(f.api.buildPackagedDaemon(), /packaged_acp_sdk_version_not_bundled/u); + }); + await t.test("foreign same-suffix module is never rewritten", async t => { + const f = await fixture(t), foreign = join(f.root, "foreign/features/execution-engines/acp-sdk-version.js"); + newFile(foreign, readFileSync(f.acpDist)); + writeFileSync(f.distEntry, `export * from ${JSON.stringify(foreign)};\n`); + await assert.rejects(f.api.buildPackagedDaemon(), /packaged_acp_sdk_version_module_changed/u); + }); + await t.test("a second actual esbuild load of the helper is rejected", async t => { + const f = await fixture(t); + writeFileSync(f.distEntry, readFileSync(f.distEntry, "utf8") + + 'export { readInstalledAcpSdkVersion as duplicate } from "./features/execution-engines/acp-sdk-version.js?duplicate";\n'); + f.state.beforeBuild = options => options.plugins.unshift({ name: "synthetic-second-helper-load", setup(builder) { + builder.onResolve({ filter: /acp-sdk-version\.js\?duplicate$/ }, () => ({ path: f.acpDist, namespace: "synthetic-duplicate" })); + } }); + await assert.rejects(f.api.buildPackagedDaemon(), /packaged_acp_sdk_version_module_reloaded/u); + }); + await t.test("reviewed helper retains its original source capture and final identity fence", async t => { + const f = await fixture(t); + f.state.beforeBuild = () => writeFileSync(f.acpModule, readFileSync(f.acpModule, "utf8") + "\n// changed after capture\n"); + await assert.rejects(f.api.buildFreshPackagedDaemonForMacOffice(), /packaged_office_daemon_source_input_invalid/u); + }); +}); + +test("ACP SDK binding fails closed on invalid manifests and post-build changes", async t => { + for (const mode of ["dist", "reviewed"]) for (const value of [undefined, "", " ", 2, null, "malformed", "wrong-name", "missing"]) { + await t.test(`${mode}: manifest ${String(value)}`, async t => { + const f = await fixture(t, { mode }); + const originalManifest = JSON.parse(readFileSync(f.sdkManifest, "utf8")); + if (value === "missing") rmSync(f.sdkManifest); + else writeFileSync(f.sdkManifest, value === "malformed" ? "{" : JSON.stringify({ + ...originalManifest, + name: value === "wrong-name" ? "unrelated-sdk" : "@agentclientprotocol/sdk", version: value === "wrong-name" ? "9.9.9" : value, + })); + await assert.rejects(bundleVersionFixture(f, mode), /packaged_acp_sdk_version_(?:manifest|resolution)_/u); + const standalone = runVersion(f.acpDist, f.root); + assert.notEqual(standalone.status, 0, "source mode must not substitute an environment version"); + }); + } + for (const mode of ["dist", "reviewed"]) await t.test(`${mode}: manifest changed during esbuild`, async t => { + const f = await fixture(t, { mode }); + f.state.afterBuild = () => writeFileSync(f.sdkManifest, readFileSync(f.sdkManifest, "utf8") + " "); + await assert.rejects(bundleVersionFixture(f, mode), /packaged_acp_sdk_version_manifest_changed/u); + }); +}); diff --git a/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs b/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs index 6b8779670..9d16c9d5d 100644 --- a/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs +++ b/apps/desktop/tests/packaged-office-daemon-source-inputs.test.mjs @@ -6,7 +6,7 @@ import { realpathSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { registerHooks, syncBuiltinESMExports } from "node:module"; -import { tmpdir } from "node:os"; +import { release, tmpdir } from "node:os"; import { dirname, join, resolve, sep } from "node:path"; import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -18,6 +18,16 @@ const repositoryRoot = realpathSync(fileURLToPath(new URL("../../../", import.me const workspaceNames = ["browser-host", "contracts", "core", "daemon", "localization", "plugin-sdk", "providers", "remote-protocol", "runner-pi"]; const reviewedLeaf = "packages/core/src/original-office-host-bridge.ts"; const compiledLeaf = "packages/core/dist/original-office-host-bridge.js"; +test("Office fixed closure contains no duplicate packaging inputs", () => { + assert.equal(new Set(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS).size, + PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.length); +}); +test("Office fixed closure pins ACP version and bounded MCP read sources", () => { + for (const path of [ + "packages/daemon/src/features/execution-engines/acp-sdk-version.ts", + "packages/daemon/src/features/mcp/host-stdio-read-buffer.ts", + ]) assert.equal(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), true, path); +}); const currentMainFeatureClosureInputs = [ "packages/daemon/src/server/request-auth.ts", "packages/contracts/src/artifacts.ts", @@ -723,6 +733,21 @@ async function approvedSyntheticFixture(t) { newFile(join(sdkRoot, "SDKSettings.json"), "EXPLICIT SYNTHETIC SDK"); const headerNames = ["node_api.h", "js_native_api.h", "js_native_api_types.h", "node_api_types.h"]; for (const name of headerNames) newFile(join(headersRoot, name), "EXPLICIT SYNTHETIC HEADER:" + name); + const helperReviewPath = join(root, "native/sandbox/reviewed-codesign-source-v1.json"); + const helperReview = JSON.parse(readFileSync(helperReviewPath, "utf8")); + helperReview.schemaVersion = 1; + delete helperReview.additionalOs; + helperReview.os = { + release: release(), backendHelperVersion: `ling-seatbelt-v1:${release()}:${hash("EXPLICIT SYNTHETIC BACKEND VERSION")}`, + backendSha256: hash("EXPLICIT SYNTHETIC BACKEND"), codesignSha256: hash("EXPLICIT SYNTHETIC CODESIGN"), + templateSha256: hash("EXPLICIT SYNTHETIC TEMPLATE"), taskgatedBinding: "reviewed_xml_v1", + }; + writeFileSync(helperReviewPath, JSON.stringify(helperReview)); + // Its unchanged reviewed source files were already copied by the fixed + // packaging inventory; this is a disposable copy, not a product row update. + for (const name of Object.keys(helperReview.sourceSha256)) { + assert.equal(hash(readFileSync(join(root, "native/sandbox", name))), helperReview.sourceSha256[name]); + } const review = { schemaVersion: 1, reviewId: "packaged_office_native_v1", approval: "approved_source_and_packaging_review", protocolVersion: 2, napiVersion: 8, deploymentTarget: "13.0", @@ -737,12 +762,6 @@ async function approvedSyntheticFixture(t) { }; const reviewPath = join(nativeOffice, "reviewed-packaged-source-v1.json"); newFile(reviewPath, JSON.stringify(review)); - const helperReview = JSON.parse(readFileSync(join(root, "native/sandbox/reviewed-codesign-source-v1.json"), "utf8")); - // Its unchanged reviewed source files were already copied by the fixed - // packaging inventory; this is a disposable copy, not a product row update. - for (const name of Object.keys(helperReview.sourceSha256)) { - assert.equal(hash(readFileSync(join(root, "native/sandbox", name))), helperReview.sourceSha256[name]); - } const counts = { nativeCompilerCalls: 0, signCalls: 0, nativeVerifyCalls: 0, helperNormalizeCalls: 0 }; const builds = []; const counterKey = `__LING_EXPLICIT_NATIVE_FIXTURE_${root.split("/").at(-1).replaceAll("-", "_")}__`; diff --git a/apps/desktop/tests/packaged-office-native-publication.test.mjs b/apps/desktop/tests/packaged-office-native-publication.test.mjs index 755107e19..d78b9f407 100644 --- a/apps/desktop/tests/packaged-office-native-publication.test.mjs +++ b/apps/desktop/tests/packaged-office-native-publication.test.mjs @@ -110,6 +110,72 @@ const addedDependencyGroups = Object.freeze({ "packages/localization/src/index.ts", "packages/runner-pi/src/index.ts", "packages/providers/src/index.ts", "packages/browser-host/src/index.ts", "packages/remote-protocol/src/index.ts", "packages/plugin-sdk/src/index.ts", ], + localServiceIpc: [ + "apps/desktop/main/local-services.ts", "apps/desktop/main/main.ts", "apps/desktop/main/ipc.ts", + "apps/desktop/preload/main-window.ts", "apps/desktop/shared/ipc-channels.ts", + "apps/desktop/shared/desktop-capabilities.ts", "apps/desktop/shared/local-service-capabilities.ts", + "apps/web/src/api/client.ts", + ], + reachableRuntime: [ + "packages/contracts/src/channel-sessions-validation.ts", + "packages/contracts/src/learning-reviews.ts", + "packages/contracts/src/memory-derivatives.ts", + "packages/contracts/src/memory-wiki.ts", + "packages/core/src/channel-session-types.ts", + "packages/core/src/learning-review-types.ts", + "packages/core/src/memory-derivative-types.ts", + "packages/core/src/memory-wiki-types.ts", + "packages/core/src/storage/sqlite/channel-session-admission.ts", + "packages/core/src/storage/sqlite/channel-session-lineage.ts", + "packages/core/src/storage/sqlite/channel-session-migration.ts", + "packages/core/src/storage/sqlite/channel-session-notification.ts", + "packages/core/src/storage/sqlite/channel-session-store.ts", + "packages/core/src/storage/sqlite/learning-review-migration.ts", + "packages/core/src/storage/sqlite/learning-review-store.ts", + "packages/core/src/storage/sqlite/memory-derivative-migration.ts", + "packages/core/src/storage/sqlite/memory-derivative-store.ts", + "packages/core/src/storage/sqlite/memory-search-index.ts", + "packages/core/src/storage/sqlite/memory-wiki-store.ts", + "packages/core/src/storage/sqlite/procedure-observation-job-migration.ts", + "packages/core/src/storage/sqlite/skill-definition-revision-migration.ts", + "packages/core/src/storage/sqlite/skill-definition-revision-store.ts", + "packages/daemon/src/features/channels/conversation-runtime-metrics-scope.ts", + "packages/daemon/src/features/channels/session-routes.ts", + "packages/daemon/src/features/learning/coordinator.ts", + "packages/daemon/src/features/learning/evidence.ts", + "packages/daemon/src/features/learning/extractor.ts", + "packages/daemon/src/features/learning/model.ts", + "packages/daemon/src/features/learning/routes.ts", + "packages/daemon/src/features/learning/scoped-candidates.ts", + "packages/daemon/src/features/memory/embedding-client.ts", + "packages/daemon/src/features/memory/lancedb-provider.ts", + "packages/daemon/src/features/memory/recall-service.ts", + "packages/daemon/src/features/memory/semantic-registry.ts", + "packages/daemon/src/features/memory/semantic-routes.ts", + "packages/daemon/src/features/memory/semantic-service.ts", + "packages/daemon/src/features/memory/wiki-compiler.ts", + "packages/daemon/src/features/memory/wiki-routes.ts", + "packages/daemon/src/features/skills/procedure-observation-service.ts", + "packages/daemon/src/features/skills/revision-notices.ts", + ], +}); + +test("real Office source approval record binds every fixed packaging and native input", () => { + const review = JSON.parse(readFileSync(join(repositoryRoot, "native/office-local/reviewed-packaged-source-v1.json"), "utf8")); + assert.deepEqual(Object.keys(review.packagingSha256).sort(), [...PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS].sort()); + for (const path of PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS) { + assert.equal(review.packagingSha256[path], hash(readFileSync(join(repositoryRoot, path))), path); + } + assert.deepEqual(Object.keys(review.sourceSha256).sort(), [...OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS].sort()); + for (const path of OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS) { + assert.equal(review.sourceSha256[path], hash(readFileSync(join(repositoryRoot, "native/office-local", path))), path); + } +}); + +test("independently reviewed reachable runtime dependencies remain explicit packaging inputs", () => { + for (const path of addedDependencyGroups.reachableRuntime) { + assert.ok(PACKAGED_OFFICE_NATIVE_PACKAGING_SOURCE_INPUTS.includes(path), path); + } }); test("Office signing admission remains an explicit authored root and packaging source input", () => { @@ -171,13 +237,13 @@ test("Office bundled-plugin staging code and copied metadata remain in the fixed }); /** Disposable, explicitly synthetic compiler/signature fixture. It never approves product sources. */ -async function fixture(t, { portable = false, onCompilerVersion } = {}) { - // These fixtures verify independent release approval, including the portable row. - const previousReviewRequired = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; +async function fixture(t, { portable = false, releaseReview = true, onCompilerVersion } = {}) { + const previousReleaseReview = process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + if (releaseReview) process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = "1"; + else delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; t.after(() => { - if (previousReviewRequired === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; - else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = previousReviewRequired; + if (previousReleaseReview === undefined) delete process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED; + else process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED = previousReleaseReview; }); const root = realpathSync(mkdtempSync(join(tmpdir(), "ling-office-publication-test-"))); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -282,6 +348,51 @@ async function fixture(t, { portable = false, onCompilerVersion } = {}) { toolchainCounts: () => ({ compilerCalls, discoveryCalls }) }; } +test("Office unsigned developer publication retains reviewed toolchain admission (synthetic compiler/signature)", async t => { + await t.test("default local lane records the admitted compiler version and accepts fresh evidence", async t => { + const f = await fixture(t, { portable: true, releaseReview: false }), row = readFileSync(f.reviewPath); + assert.equal(process.env.LING_PACKAGED_RELEASE_REVIEW_REQUIRED, undefined); + const observed = f.reviewApi.readLocalPackagedOfficeSource(); + assert.equal(observed.review.compiler.version, f.review.toolchains[0].compilerVersionFirstLine); + const token = f.api.buildFreshPackagedOfficeNativePublication(); + assert.equal(f.api.readOriginalPackagedOfficeNativePublication(token).schemaVersion, 1); + assert.ok(f.toolchainCounts().compilerCalls > 0); + assert.deepEqual(f.counts(), { signCalls: 1, verifyCalls: 1 }); + assert.deepEqual(readFileSync(f.reviewPath), row); + }); + await t.test("default local lane rejects unreviewed identities before invoking a compiler", async t => { + for (const [name, mutate] of [ + ["compiler", f => { writeFileSync(join(f.root, "toolchain/clang++"), "UNREVIEWED COMPILER"); }], + ["SDK", f => { writeFileSync(join(f.root, "sdk/SDKSettings.json"), JSON.stringify({ CanonicalName: "macosx99.0", Version: "99.0" })); }], + ["headers", f => { writeFileSync(join(f.root, "headers/node_api.h"), "UNREVIEWED HEADER"); }], + ["empty profiles", f => { f.review.toolchains = []; writeFileSync(f.reviewPath, JSON.stringify(f.review)); }], + ]) { + await t.test(name, async t => { + const f = await fixture(t, { portable: true, releaseReview: false }); + mutate(f); + assert.throws(() => f.api.buildFreshPackagedOfficeNativePublication(), /toolchain_invalid/u); + assert.equal(f.toolchainCounts().compilerCalls, 0); + assert.equal(f.counts().signCalls, 0); + }); + } + }); + await t.test("default local lane rejects an admitted compiler reporting an unreviewed version", async t => { + const f = await fixture(t, { portable: true, releaseReview: false, + onCompilerVersion: () => "Apple clang version 99.0.0 (clang-9900.1.0)" }); + assert.throws(() => f.api.buildFreshPackagedOfficeNativePublication(), /toolchain_invalid/u); + assert.equal(f.toolchainCounts().compilerCalls, 1); + assert.equal(f.counts().signCalls, 0); + }); + await t.test("default local lane still rejects changed native sources before tool discovery", async t => { + const f = await fixture(t, { portable: true, releaseReview: false }); + writeFileSync(join(f.root, "native/office-local", OFFICE_LOCAL_NATIVE_REVIEW_SOURCE_INPUTS[0]), "UNREVIEWED NATIVE SOURCE"); + assert.throws(() => f.api.buildFreshPackagedOfficeNativePublication(), /source_review_invalid/u); + assert.equal(f.toolchainCounts().discoveryCalls, 0); + assert.equal(f.toolchainCounts().compilerCalls, 0); + assert.equal(f.counts().signCalls, 0); + }); +}); + test("Office publication private origin and final-byte contracts (synthetic compiler/signature, not packaged validation)", async t => { await t.test("portable reader rejects stale authored closure before any tool discovery or compiler execution", async t => { const f = await fixture(t, { portable: true }); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 40ab67702..258cd82c6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -29,21 +29,22 @@ "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/native": "^7.5.1", "@react-navigation/native-stack": "^7.21.0", - "expo": "~58.0.16", - "expo-camera": "~58.0.11", - "expo-dev-client": "~58.0.14", - "expo-font": "~58.0.9", + "expo": "58.0.21", + "expo-camera": "~58.0.14", + "expo-dev-client": "~58.0.17", + "expo-font": "~58.0.11", "expo-image-picker": "~58.0.10", - "expo-network": "~58.0.3", + "expo-network": "~58.0.4", "expo-secure-store": "~58.0.2", "react": "19.3.0", - "react-native": "0.88.0", + "react-native": "0.88.1", "react-native-safe-area-context": "~5.10.1", "react-native-screens": "~4.30.0", "@expo/dom-webview": "~58.0.2" }, "devDependencies": { "expo-doctor": "1.21.4", + "expo-mcp": "0.2.4", "react-dom": "19.3.0", "react-types-mobile": "npm:@types/react@19.3.0", "typescript": "~7.0.2" diff --git a/apps/mobile/test/dependency-security-tooling.test.mjs b/apps/mobile/test/dependency-security-tooling.test.mjs index 649bc7249..445294bf1 100644 --- a/apps/mobile/test/dependency-security-tooling.test.mjs +++ b/apps/mobile/test/dependency-security-tooling.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import test from "node:test"; +import { pathToFileURL } from "node:url"; const localRequire = createRequire(import.meta.url); const expoRequire = createRequire(localRequire.resolve("expo/package.json")); @@ -12,17 +16,27 @@ const forge = createRequire(signingPath)("node-forge"); const metroRequire = createRequire(expoRequire.resolve("metro/package.json")); const fileMapRequire = createRequire(metroRequire.resolve("metro-file-map/package.json")); const braces = createRequire(fileMapRequire.resolve("micromatch"))("braces"); -const { z } = cliRequire("zod"); -const { z: legacyZod } = cliRequire("zod/v3"); const { PluginSchema, DevToolsPluginOutputSchema } = - cliRequire("./build/src/start/server/DevToolsPlugin.schema.js"); + cliRequire("./build/src/start/server/devtools/DevToolsPlugin.schema.js"); +const pluginPath = cliRequire.resolve("./build/src/start/server/devtools/DevToolsPlugin.js"); +const { DevToolsPlugin } = cliRequire(pluginPath); +const { validate, ValidationError } = createRequire(pluginPath)("@expo/schema-utils"); +const { DevToolsPluginCliExtensionResults } = + cliRequire("./build/src/start/server/devtools/DevToolsPluginCliExtensionResults.js"); const { createMCPDevToolsExtensionSchema } = - cliRequire("./build/src/start/server/createMCPDevToolsExtensionSchema.js"); + cliRequire("./build/src/start/server/mcp/createMCPDevToolsExtensionSchema.js"); const { addMcpCapabilities } = - cliRequire("./build/src/start/server/MCPDevToolsPluginCLIExtensions.js"); + cliRequire("./build/src/start/server/mcp/MCPDevToolsPluginCLIExtensions.js"); +const { maybeCreateMCPServerAsync } = cliRequire("./build/src/start/server/mcp/MCP.js"); +// Match MCP.js: resolve both Zod and the tunnel relative to expo-mcp, not the CLI. +const expoMcpRequire = createRequire(localRequire.resolve("expo-mcp")); +const { z: legacyZod } = expoMcpRequire("zod"); +const tunnelPath = expoMcpRequire.resolve("@expo/mcp-tunnel"); +const { zodToJsonSchema } = createRequire(tunnelPath)("zod-to-json-schema"); +const { TunnelMcpServerProxy } = await import(pathToFileURL(tunnelPath).href); const daemonRequire = createRequire(new URL("../../../packages/daemon/package.json", import.meta.url)); const mcpSdkRequire = createRequire(daemonRequire.resolve("@modelcontextprotocol/sdk/server/mcp.js")); -const { zodToJsonSchema } = mcpSdkRequire("zod-to-json-schema"); +const { z } = mcpSdkRequire("zod"); const pem = generateKeyPairSync("rsa", { modulusLength: 2048, publicKeyEncoding: { type: "spki", format: "pem" }, @@ -148,7 +162,7 @@ test("braces preserves normal glob compilation, expansion and literal escaping", assert.equal(braces.stringify(braces.parse(boundary)), boundary); }); -test("Expo's Zod rejects oversized bounded arrays before visiting elements", () => { +test("fixed Zod still rejects oversized bounded arrays before visiting elements", () => { let elementVisits = 0; const schema = z.array(z.string().transform((value) => { elementVisits++; @@ -164,7 +178,8 @@ test("Expo's Zod rejects oversized bounded arrays before visiting elements", () test("Expo's DevTools plugin schema preserves command and parameter validation", () => { const plugin = devToolsPlugin(); - assert.deepEqual(PluginSchema.parse(plugin), plugin); + assert.doesNotThrow(() => validate(PluginSchema, plugin)); + assert.deepEqual(new DevToolsPlugin(plugin, "/fixture").plugin, plugin); for (const invalid of [ { ...plugin, packageName: "" }, { ...plugin, packageRoot: "" }, @@ -180,7 +195,8 @@ test("Expo's DevTools plugin schema preserves command and parameter validation", }, }, ]) { - assert.equal(PluginSchema.safeParse(invalid).success, false); + assert.throws(() => validate(PluginSchema, invalid), ValidationError); + assert.throws(() => new DevToolsPlugin(invalid, "/fixture"), /Invalid plugin configuration/); } }); @@ -190,7 +206,9 @@ test("Expo's DevTools output schema preserves text, audio and image validation", { type: "audio", url: "https://example.com/audio.mp3" }, { type: "image", url: "https://example.com/image.png", text: "Preview" }, ]; - assert.deepEqual(DevToolsPluginOutputSchema.parse(output), output); + assert.doesNotThrow(() => validate(DevToolsPluginOutputSchema, output)); + const results = new DevToolsPluginCliExtensionResults(); + assert.deepEqual(results.parseOutputText(JSON.stringify(output)), output); for (const invalid of [ { type: "video", url: "https://example.com/video.mp4" }, { type: "text", text: "Missing level" }, @@ -198,13 +216,18 @@ test("Expo's DevTools output schema preserves text, audio and image validation", { type: "audio", url: "invalid-url" }, { type: "image" }, ]) { - assert.equal(DevToolsPluginOutputSchema.safeParse([invalid]).success, false); + assert.throws(() => validate(DevToolsPluginOutputSchema, [invalid]), ValidationError); + const parsed = results.parseOutputText(JSON.stringify([invalid])); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].type, "text"); + assert.equal(parsed[0].level, "error"); + assert.match(parsed[0].text, /^Invalid JSON:/); } }); test("Expo's generated MCP schema preserves strict commands and optional typed parameters", () => { const plugin = devToolsPlugin(); - const schema = createMCPDevToolsExtensionSchema(plugin); + const schema = createMCPDevToolsExtensionSchema(plugin, legacyZod); const parameters = { command: "inspect", query: "status", count: 2, confirm: true }; assert.deepEqual(schema.parse(parameters), parameters); assert.deepEqual(schema.parse({ command: "ping" }), { command: "ping" }); @@ -218,11 +241,11 @@ test("Expo's generated MCP schema preserves strict commands and optional typed p ]) { assert.equal(schema.safeParse(invalid).success, false); } - assert.throws(() => createMCPDevToolsExtensionSchema({ ...plugin, cliExtensions: undefined }), /no commands defined/); + assert.throws(() => createMCPDevToolsExtensionSchema({ ...plugin, cliExtensions: undefined }, legacyZod), /no commands defined/); assert.throws(() => createMCPDevToolsExtensionSchema({ ...plugin, cliExtensions: { ...plugin.cliExtensions, commands: [] }, - }), /no commands defined/); + }, legacyZod), /no commands defined/); }); test("Expo's MCP registration retains constraints through the legacy JSON schema converter", async () => { @@ -244,7 +267,7 @@ test("Expo's MCP registration retains constraints through the legacy JSON schema return [plugin]; }, }, - }); + }, legacyZod); assert.equal(registrations.length, 1); const [name, config, handler] = registrations[0]; assert.equal(name, plugin.packageName); @@ -252,7 +275,7 @@ test("Expo's MCP registration retains constraints through the legacy JSON schema assert.equal(typeof handler, "function"); assert.equal(config.inputSchema.parameters.safeParse({ command: "local" }).success, false); - // Expo's optional MCP tunnel wraps inputSchema with Zod 3 before this conversion. + // Use the converter consumed by the actual optional MCP tunnel. const converted = zodToJsonSchema(legacyZod.object(config.inputSchema)); assert.equal(converted.type, "object"); assert.deepEqual(converted.required, ["parameters"]); @@ -267,3 +290,115 @@ test("Expo's MCP registration retains constraints through the legacy JSON schema assert.deepEqual(parameters.required, ["command"]); assert.equal(parameters.additionalProperties, false); }); + +test("Expo's schema-utils rejects oversized bounded arrays before visiting elements", () => { + // Drop schema-utils' symbol cache before adding an independent array bound. + const schema = { ...structuredClone(DevToolsPluginOutputSchema), maxItems: 2 }; + let elementVisits = 0; + const output = [ + { type: "text", text: "Ready", level: "info" }, + { type: "audio", url: "https://example.com/audio.mp3" }, + { type: "image", url: "https://example.com/image.png" }, + ]; + const observed = (items) => new Proxy(items, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) elementVisits++; + return Reflect.get(target, key, receiver); + }, + }); + assert.throws(() => validate(schema, observed(output)), (error) => + error instanceof ValidationError && error.errors[0].keyword === "maxItems"); + assert.equal(elementVisits, 0); + assert.doesNotThrow(() => validate(schema, observed(output.slice(0, 2)))); + assert.equal(elementVisits, 2); + assert.equal(Object.hasOwn(cliRequire("./package.json").dependencies, "zod"), false); + const schemaRequire = createRequire(pluginPath); + assert.equal(Object.hasOwn(schemaRequire("@expo/schema-utils/package.json").dependencies ?? {}, "zod"), false); +}); + +test("Expo's real MCP entry injects compatible Zod and retains constraints through its tunnel", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "ling-expo-mcp-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const projectRoot = dirname(localRequire.resolve("../package.json")); + const plugin = devToolsPlugin(); + plugin.packageRoot = directory; + plugin.cliExtensions.entryPoint = "tools.cjs"; + plugin.cliExtensions.commands.push({ + name: "local", + title: "Local only", + environments: ["cli"], + parameters: [{ name: "localPath", type: "text" }], + }); + const invocations = join(directory, "invocations.jsonl"); + await writeFile(join(directory, "tools.cjs"), ` + const fs = require("node:fs"); + const invocation = { command: process.argv[2], args: JSON.parse(process.argv[3]) }; + fs.appendFileSync(${JSON.stringify(invocations)}, JSON.stringify(invocation) + "\\n"); + process.stdout.write(JSON.stringify([{ type: "text", text: JSON.stringify(invocation), level: "info" }])); + `); + const previousMcpServer = process.env.EXPO_UNSTABLE_MCP_SERVER; + process.env.EXPO_UNSTABLE_MCP_SERVER = "true"; + t.after(() => { + if (previousMcpServer === undefined) delete process.env.EXPO_UNSTABLE_MCP_SERVER; + else process.env.EXPO_UNSTABLE_MCP_SERVER = previousMcpServer; + }); + const server = await maybeCreateMCPServerAsync({ + devServerManager: { + projectRoot, + devtoolsPluginManager: { + async queryPluginsAsync() { + return [new DevToolsPlugin(plugin, projectRoot)]; + }, + }, + getDefaultDevServer() { + return { getJsInspectorBaseUrl: () => "http://127.0.0.1:8081" }; + }, + }, + devServerUrl: "http://127.0.0.1:8081", + }); + assert.ok(server instanceof TunnelMcpServerProxy, "real MCP entry must not silently return null"); + t.after(() => server.closeAsync()); + assert.equal(server.transport.ws, undefined, "schema registration must not open a network connection"); + const tools = server.getRegisteredTools(); + assert.ok(tools.has("open_devtools"), "expo-mcp's own tools are registered through the same tunnel"); + const converted = tools.get(plugin.packageName).inputSchema; + assert.equal(converted.type, "object"); + assert.deepEqual(converted.required, ["parameters"]); + const parameters = converted.properties.parameters; + assert.equal(parameters.type, "object"); + assert.deepEqual(parameters.properties.command.enum, ["inspect", "ping"]); + assert.equal(parameters.properties.command.type, "string"); + assert.equal(parameters.properties.query.type, "string"); + assert.equal(parameters.properties.count.type, "number"); + assert.equal(parameters.properties.confirm.type, "boolean"); + assert.equal(Object.hasOwn(parameters.properties, "localPath"), false); + assert.deepEqual(parameters.required, ["command"]); + assert.equal(parameters.additionalProperties, false); + const input = { command: "inspect", query: "status", count: 2, confirm: true }; + assert.doesNotThrow(() => validate(converted, { parameters: input })); + assert.doesNotThrow(() => validate(converted, { parameters: { command: "ping" } })); + for (const invalid of [ + {}, + { ...input, command: "local" }, + { ...input, query: 1 }, + { ...input, count: "2" }, + { ...input, confirm: "true" }, + { ...input, unexpected: true }, + ]) { + assert.throws(() => validate(converted, { parameters: invalid }), ValidationError); + } + assert.deepEqual(await server.handleToolCall({ name: plugin.packageName, arguments: { parameters: input } }), { + content: [{ type: "text", text: JSON.stringify({ command: "inspect", args: { query: "status", count: 2, confirm: true } }), level: "info" }], + }); + const execution = await readFile(invocations, "utf8"); + for (const invalid of [ + { command: "local", localPath: "/tmp/not-executable" }, + { ...input, query: 1 }, + { ...input, unexpected: true }, + ]) { + const result = await server.handleToolCall({ name: plugin.packageName, arguments: { parameters: invalid } }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /not found|expected string|Expected 3 parameter/); + } + assert.equal(await readFile(invocations, "utf8"), execution, "invalid calls must not spawn the plugin"); +}); diff --git a/docs/maintenance/ci.md b/docs/maintenance/ci.md index 7a87decb8..5ca9db294 100644 --- a/docs/maintenance/ci.md +++ b/docs/maintenance/ci.md @@ -2,13 +2,15 @@ 本文先说明当前行为,再记录平台分工和耗时优化的后续方案。分类收窄、Draft 分档和严格汇总门已经接入;2026-10-19 将默认分支完整定时回归恢复为每日运行,并补齐本地化包的 CI 入口。 +分片执行、文件 watchdog、报告与耗时缓存见 [CI 测试执行说明](../../scripts/ci/README.md);依赖安全补丁、兼容边界与验证入口见[补丁维护说明](../../patches/README.md)。 + ## 当前行为 -本次修改基于 `main@912e3e8e`。PR 工作流在推送修改后应用;每日定时配置只在合入默认分支后生效。该仓库当前公开,标准 GitHub-hosted Linux/macOS runner 的运行分钟免费;并发、单次时长、缓存与制品存储仍有独立限制,不能据此承诺立即开跑或无限存储。 +本节最初记录于 `main@912e3e8e`;当前规则以 [CI 工作流](../../.github/workflows/ci.yml)和[执行策略](../../scripts/ci/execution-policy.mjs)为准。PR 工作流在推送修改后应用;每日定时配置只在合入默认分支后生效。该仓库当前公开,标准 GitHub-hosted Linux/macOS runner 的运行分钟免费;并发、单次时长、缓存与制品存储仍有独立限制,不能据此承诺立即开跑或无限存储。 CI 会先根据整个 Pull Request 的变更路径选择最小安全范围: -- 只有 `README.md`、`docs/`、Issue 模板或已登记维护脚本变更时,运行文档链接、公开仓库安全、CI 边界契约和空白检查;历史密钥扫描在该轻量范围下跳过。维护脚本白名单仅包含 `check-markdown-links.mjs`、`check-public-repo-safety.mjs` 和后者的测试,新增脚本仍保守处理。 +- 只有 `README.md`、`docs/`、Issue 模板或已登记维护脚本变更时,运行文档链接、公开仓库安全、CI 边界契约和空白检查;`complete` 档位仍运行历史密钥扫描,文档范围不会关闭该扫描。维护脚本白名单仅包含 `check-markdown-links.mjs`、`check-public-repo-safety.mjs` 和后者的测试,新增脚本仍保守处理。 - 工作区源码变更时,[分类器](../../scripts/ci/classify-scope.mjs)会沿 workspace 依赖图扩大到所有直接和间接消费者;仅改测试时选择所属包,并补充已识别的跨包测试辅助文件使用方。 - 任意 `package.json`、锁文件、非白名单 `.github/`/`scripts/`、补丁、TypeScript/构建配置等变更会进入全量 CI;未知路径也回退全量。维护脚本与源码混合时继续按受影响包及使用方选择。 - [工作流](../../.github/workflows/ci.yml)由 Pull Request、手动运行和每日定时触发,没有 `main` push 触发。定时为每天 UTC 03:17,即北京时间 11:17;手动和定时运行始终走现有完整 Linux/macOS 矩阵,即使当天没有提交也执行。GitHub 定时任务可能排队或延迟,不保证准点启动。没有启用成功证据去重或每日 Linux 单独分档。 @@ -24,9 +26,9 @@ CI 会先根据整个 Pull Request 的变更路径选择最小安全范围: | 手动运行 | 目标分支上的完整验证 | | 定时运行 | 默认分支每天 UTC 03:17(北京时间 11:17)的完整验证 | -该分支的 Daemon 测试拆为四个 `macos-26` shard,使用稳定的文件分配和独立的 `LING_STACK_HOME`。每个 shard 以 `--test-concurrency=1` 运行,避免测试间共享状态;新增或删除测试文件会自动重新平衡。完整执行档位下选择 Daemon 时,还会运行 Linux restricted-command 拒绝契约和一个真实 macOS helper/Seatbelt smoke 任务。 +当前 Core 测试拆为四个 Linux shard,Daemon 测试拆为四个 `macos-26` shard,每个 shard 使用两个文件 worker;每个文件内部仍以 `--test-concurrency=1` 串行运行。每个文件有独立的运行目录和 `LING_STACK_HOME`,新增或删除测试文件会自动重新平衡,执行前验证 discovery 与计划守恒。完整执行档位下选择 Daemon 时,还会运行 Linux restricted-command 拒绝契约和一个真实 macOS helper/Seatbelt smoke 任务。 -Daemon 分片的任务上限为 40 分钟,包含依赖安装及原生准备。运行 `39274142315` 的分片 1 在 25 分钟上限前已连续通过 1,584 个顶层测试,尚有文件未执行;该调整只补足运行预算,未减少测试或修改并发,也不代表性能优化。 +Daemon 分片的任务上限为 40 分钟,包含依赖安装及原生准备。历史运行 `39274142315` 的分片 1 在 25 分钟上限前已连续通过 1,584 个顶层测试,尚有文件未执行;当时扩大上限只补足运行预算,未减少测试或修改并发,也不代表性能优化。当前两 worker 的执行结构不改变该任务上限。 完整 Daemon 分片使用 macOS,是为了让 Darwin 专属正向用例实际执行。[平台契约测试](../../scripts/ci/platform-lanes.test.mjs)当前明确保护这一要求,不能只改 `runs-on`,让 Linux 上的跳过被当成原生验证成功。 @@ -46,7 +48,7 @@ Linux 构建任务在选中 CLI 时运行真实 daemon 生命周期和新工作 | --- | --- | | 本地化类型/测试入口 | 本次修复此前被选中却未执行的漏检,并增加入口覆盖契约 | | CodeQL / Copilot Autofix | 尚未启用;建议先为 JavaScript/TypeScript 配置 CodeQL 默认扫描,观察首次结果。Autofix 提供修复建议,需要审查与测试,不自动合并 | -| 仓库级密钥扫描/推送保护 | GitHub 开关目前关闭;建议开启。现有完整非文档 CI 会运行 Gitleaks,但它在上传后执行,且 Draft/文档范围跳过,不能代替推送时拦截 | +| 仓库级密钥扫描/推送保护 | 2026-10-19 评估时 GitHub 开关关闭;该历史观察不代表当前设置。`complete` CI 包含文档范围的 Gitleaks 历史扫描,但它在上传后执行,Draft preview 延后扫描,不能代替推送时拦截 | | 新披露的依赖漏洞 | Dependabot 告警、安全更新及每周版本更新已配置;CI 另测已有安全补丁与许可策略。若增加漏洞严重性阻断,应先核对现存告警、补丁和无上游修复的基线,不能将旧告警直接当作本次改动引入 | | 浏览器与桌面交互 | 选中 Web 时执行六项生产预览 Playwright smoke,使用模拟 daemon 数据并保留截图;Desktop 主要只有类型检查。后续可逐步扩大交互覆盖;不把每日现有矩阵成功视为桌面打包或设备验收 | @@ -137,7 +139,7 @@ Linux 每日回归仅在同一提交、同一检查方案已有有效的完整 | --- | --- | --- | | P1:规则与开发阶段分档(已接入) | 分类器、执行策略、CI 工作流、现有 CI 契约测试、本文 | 模板/已登记脚本不误触全量;未知配置仍全量;Draft 不冒充完整通过;转正式和 `full-ci` 标签正确补齐;所有必需任务意外跳过均被拒绝;定时完整矩阵每日运行 | | P2:平台分工 | 测试清单、Daemon 分片执行器、平台契约、工作流 | discovery 与清单守恒;Linux 通用组通过;原有 Darwin 正向/负向用例实际执行;额外 Linux 拒绝与 native smoke 保留;共享变更正确扩大平台范围 | -| P3:耗时与定时优化 | Core 测试执行与耗时记录、必要的 CI 任务、定时选择 | 先测量文件耗时和实际并发;重复数据库初始化仅作为候选原因;若拆 Core 分片,从原 unit job 排除,确保不重复、不漏文件;去重拒绝失败/陈旧证据,周回归仍执行 | +| P3:耗时与定时优化(Core 分片已接入) | Core 测试执行与耗时记录、必要的 CI 任务、定时选择 | 当前四个 Core 分片各有两个文件 worker,已从其他包的 unit 入口排除;继续测量文件耗时和实际并发,确保不重复、不漏文件;重复数据库初始化仅作为候选原因;定时去重仍未接入 | 实施前以分类和汇总契约测试覆盖文档、局部源码、共享契约、未知路径、标签、Draft 转正式、平台新增文件、上游失败和意外跳过。P2 在真实 Linux 与 macOS 上验证同一候选提交的原有覆盖后,才允许减少 macOS 任务。P3 对相同任务集合比较改动前后的累计运行时间和等待时间。 @@ -170,7 +172,7 @@ node scripts/ci/daemon-test-shard.mjs --shard=0 --shard-count=4 ## 未决事项 - **平台清单与能力影响映射尚未验证**:这是 P2 的前置条件;未完成时保留现有完整 macOS 范围,不能只切换 runner。 -- **Core 的实际耗时瓶颈尚未 profiling**:这是 P3 的前置条件;不因此阻塞 P1,未测量时不承诺分片或并发能降低费用。 +- **Core 的实际耗时瓶颈仍需 profiling**:四分片、每片两个文件 worker 已接入;仍需用同一任务集合测量瓶颈和累计运行时间,不能仅凭分片或并发承诺降低费用。 - **定时去重尚未实现**:当前按本次要求每天运行完整矩阵;若未来重新提出分层或去重,必须满足平台覆盖与成功基线规则。 以上缺口不阻塞 P1。P1 的本地契约验证不能代替 GitHub runner 实际验收,也不能证明原生平台测试已通过;运行额度仍由账户账单决定。 diff --git a/docs/release/desktop.md b/docs/release/desktop.md index 0c4160375..7bdba41d2 100644 --- a/docs/release/desktop.md +++ b/docs/release/desktop.md @@ -177,8 +177,9 @@ raw listener residue 均为 **0**。这是当前机器 development runtime 的 ```bash corepack pnpm --filter @ling-stack/desktop package:mac -hdiutil verify 'apps/desktop/release/Ling-Stack-0.0.0-macos-arm64.dmg' -unzip -t 'apps/desktop/release/Ling-Stack-0.0.0-macos-arm64.zip' +desktop_version="$(node -p "require('./apps/desktop/package.json').version")" +hdiutil verify "apps/desktop/release/Ling-Stack-${desktop_version}-macos-arm64.dmg" +unzip -t "apps/desktop/release/Ling-Stack-${desktop_version}-macos-arm64.zip" ``` unsigned job 必须验证: diff --git a/docs/release/macos-source-review-2026-11-02.md b/docs/release/macos-source-review-2026-11-02.md new file mode 100644 index 000000000..021f189c9 --- /dev/null +++ b/docs/release/macos-source-review-2026-11-02.md @@ -0,0 +1,205 @@ +# macOS development packaging source review, 2026-11-02 + +This review covers `1047b00500ec478c5bf4594f25a97e73b8dd34a3` plus the +CI/security repairs and fixed-source corrections in this working-tree snapshot. +The scope is the existing Darwin arm64, `unsigned_dev`, ad-hoc, `provider=false` +source prerequisite. It is not approval of a final App, ZIP, DMG, public signing, +notarization, installed-user behavior or the entire product/vendor codebase. + +## Independent source review + +Separate Codex contexts reviewed the proposed bytes, not just the current Git +diff or an automatically refreshed approval map: + +| Reader | Scope | Scoped decision | +| --- | --- | --- | +| `01a19b42-49cf-7b43-bb60-1932ee5273a6` | 216 changed/added/recovered Daemon source inputs, including admission, MCP buffering, shutdown, sessions, learning and memory | `APPROVE_SCOPED_DEVELOPMENT_PACKAGING` | +| `01a19b42-01dd-78c2-a64e-db9a2a87c39b` | 223 changed/added/recovered Core, contracts, runner-pi and plugin-sdk inputs | `APPROVE_SCOPED_DEVELOPMENT_PACKAGING` | +| `01a19b42-9579-7c21-990b-561cc0a95ea3` | Package/lock graph, nine active security patches, preserved prior repairs and optional provider boundary | `APPROVE_SCOPED_DEVELOPMENT_PACKAGING` | +| `01a19b41-9aa6-7831-9a35-95e1e6a981a6` | Desktop publication, authored closure, local-service IPC, Cua custody, leases, ASAR/gstack and exact source-record proposal | `APPROVE_SCOPED_DEVELOPMENT_PACKAGING` after the two corrections below | + +The Daemon and Core-family reviewed aggregates are respectively +`a9ae81b8a9cc86d09031216c3fa08e1b5c39ba414776990ecf35f837f86af54f` +and `409b2725b3ee1d98ca42565612f32b89eb72f7a563acd7eafd539219fe95d9e4`. +These two aggregates encode path-sorted `[path, sha256]` pairs as JSON plus a +newline. The main reviewer separately checked the Seedance polling timeout, +abort and cleanup delta; its 42 targeted Provider regressions passed against +fresh isolated TypeScript output. + +The final Desktop re-review independently checked the 40 recovered inputs, +all retained keys, local toolchain admission and the exact record proposal. +Its fixed-order 22-input Desktop delta aggregate is +`e73117bb876f7f7be5a1d9501f3e8f7ce0f054efe9f00264bd0591383a5dae59`. +The reader exercised the real validation functions without writing approval +files: the exact proposal and fresh evidence passed, all 40 stale-input probes +were rejected before observation, and an incorrect compiler version failed. + +These are independent contexts on the same model host, not human or +cross-model approval. An attempted Claude Code outside review was unavailable +because authentication failed; it supplies no approval or clean-review claim. + +## Findings and corrections + +The first Desktop decision was `REQUEST_CHANGES` for two reproducible blockers: + +1. The default local packaging reader called the toolchain observer without + approved profiles. Its intentionally non-executing observation returned + `compiler.version=null`, while fresh native evidence contained a version + string. The exact evidence comparison therefore rejected a valid local + build. The caller now passes the existing approved profiles and header + digests. Identity checks still precede compiler execution, the admitted + compiler's actual version is checked, and evidence equality is unchanged. +2. The fixed inventory omitted 40 reachable inputs, including nine old approved + entries that were still imported. They are now explicit fixed inputs. All + 1,544 old approval keys are retained; no build-time dependency discovery, + approval generator or automatic digest refresh was added. + +The regressions were added and run before either correction. They reproduced +the real record/keyset mismatch, missing inputs and null-version failure. +The corrected default local lane passes its positive fresh-publication test +and rejects unreviewed compiler/SDK/header identities before compiler execution, +invalid profiles, a wrong reported version and changed native sources. Existing +strict-lane, private token, immutable-record and final-byte checks remain. + +The test suite now also reads the actual committed-form Office record and checks +every packaging/native key and current digest without relying on a host +toolchain or synthesizing a replacement approval. Each recovered input is +covered by stale-byte rejection tests. + +## Fixed closure and scope + +The Babel 7.29.8 TypeScript AST audit retains all 43 authored roots and expands +literal runtime imports, value re-exports, `import()` and `require()` calls. +It resolves authored workspace exports and `dist` paths to the pinned source +inputs while retaining the established nonrecursive Main/IPC boundary. + +The final fixed inventory contains 1,706 unique inputs. The audit parsed 1,630 +authored files, reached 1,639 files and resolved 5,258 authored edges among +6,368 literal loads. No reachable input is missing, no authored import is +unresolved, no parse error remains, and no old approved input is removed. + +Four nonliteral loads have separate boundaries. The existing archive reader +and Builder configuration retain their fixed selections. The two LanceDB loads +are optional external opt-ins, disabled by default: a configured provider may +execute in the Host, but its vendor/generated/native bytes are not included in +this source approval or the locked dependency graph. Pinning the authored loader +does not approve an arbitrary configured module. + +## Adopted source record + +The exact-byte proposal was adopted only after the final Desktop approval. +It changes only `packagingSha256`: 350 old digest replacements, 162 added keys, +1,194 unchanged keys and zero removals. All 15 Office native inputs are unchanged. + +| Input | SHA-256 | +| --- | --- | +| Previous Office record | `ac04e808149c55823e0fcd3826c4d28e98f0debcd98949238c87abba4d173f67` | +| Adopted Office record | `c6484690321c00509a63967756415899e1200b1570b3e49b93c6195a0e610ecf` | +| Retained sandbox record | `40179954f462a0a3b20df46324d8db4b0e2830a1430aae761c3935a17a94b4ec` | +| Final fixed inventory module | `7855899507a5a9154100726dc96a724afeb93d5f0c8294f1af4d48ca00448eb3` | +| Fixed-order packaging aggregate | `2a96fa510a210b50aa723ae0be59124148f7f7b1bf5cc336e07dbc23c4fa9ae4` | +| Unchanged Office native aggregate | `9c5f6a1a05fe63ab96de941432ec13166af8974ec52730934d5ac348c41a854e` | + +The last two aggregates encode fixed-order `[path, sha256]` pairs as JSON plus a +newline. Schema, protocol, deployment target, headers, runtime, toolchain +profiles, signing and trust fields are not changed by this rebinding. +The prior codesign repair restores the exact historically reviewed primary +Darwin 25.6.0 row, retaining the separate Darwin 27.0.0 row. It removes the +duplicated OS identity without inventing a new approval or dropping either +previously reviewed platform. + +## Verified evidence and limits + +- All 1,871 adjacent source-input/projection/emitted-output, toolchain, authored + import, hook, ASAR, archive, human-QA-lane and codesign publication contracts + passed, with no failures or skips. Real esbuild is used where specified; + synthetic native/signature fixtures are not final artifact acceptance. +- The complete Office publication suite passed 207/207 with no failures, + cancellations or skips. This includes the actual record/keyset/digest + regression, all recovered-input stale-byte checks and the default local lane. + Synthetic compiler/signature cases remain contract tests, not signed artifacts. +- A fresh Office native build used the already approved local Xcode 26.6 / + macOS 26.5 SDK profile and reviewed N-API headers. Primitive/publication/readback + tests passed 48/48 under Node and 48/48 under actual Electron 44.9.1, with + identical before/after source fingerprints and no skips. +- Actual Electron remains 44.9.1 / Node 24.22.0 / libuv 1.52.1. No runtime, + compiler, SDK, header or OS profile is newly approved. The unreviewed default + Command Line Tools installation is not substituted for the approved Xcode. +- Locale generation from all 55 catalogs was repeated twice. All 57 generated + outputs were deterministic and byte-identical to the reviewed inputs. +- After adoption, the AST audit found all 1,706 entries unchanged against the + record, with no missing input, unresolved authored import, parse error or + removed approval. The record SHA-256 and fixed-order aggregate match the + independently approved proposal exactly. +- Both the strict source reader and default local reader passed with the actual + approved Xcode, SDK and headers, without observer mocks. Both accepted the + freshly compiled native evidence; actual Electron runtime identity matched + the existing record. Validation did not alter the approval file. +- The real codesign reader accepted distinct Darwin `25.6.0` and `27.0.0` + identities, with no duplicate OS release. All non-packaging Office policy + fields and all 15 native source digests remain identical to the prior record. + +## Final scoped verdict + +The development-packaging source prerequisite is independently approved, +adopted and verified, with no remaining blocking finding in this scope. +This does not certify the complete repository or hosted CI. No commit, push, +App, ZIP or DMG was produced or accepted by this source review. Signature, +protected-stage, Core-safety, notarization, clean-machine and GUI acceptance +remain separate checks, with no safety axis promoted by these source/test +results. + +## Main integration follow-up + +The fast-forward to main `d7227c83af77daf237e85845961cb54511d681f8` +changed one of the 1,706 pinned packaging inputs: +`packages/daemon/src/features/conversations/routes.ts`. Independent Codex reader +`01a19baa-9fcf-7972-9c42-4444e41a02cf` reviewed the complete route and its +permission-contract delta before returning +`APPROVE_SCOPED_DEVELOPMENT_PACKAGING` for that single replacement: + +- Previous digest: `eaedfb788aa45c7dd1f82b06cbf848de4e2a9a6e6b09a6e6d93cbf1a8f3cefce`. +- Approved digest: `62120da8bc9bc780d2f67d2e68e2c9bb6da2ebe3dff5625a767d2cc029c16c68`. +- Updated Office record: `f16a4ac0080c6e52d7425001fb263da964cd96921f6e14f37d69f83ce1b73c52`. +- Updated fixed-order packaging aggregate: `a41c8aba22f80bcc823a2b268c80440741f697e989d9fe2be26b8a2addc8ad51`. + +The route adds explicit permission-contract version checking and propagates +schema version 2. Validation still occurs before conversation creation or +execution; legacy compatibility and explicit full-access confirmation remain. +The reader checked all 1,706 digests and found only this mismatch, but did not +run tests or a build. The parent adopted exactly the approved value and verified +that all 1,706 current input digests now match. No other approval field, native +digest, runtime, toolchain, signing or trust axis changed. Earlier test results +above describe the pre-integration snapshot, not a hosted CI result for this +integrated revision. Final remote acceptance is a separate merge prerequisite. + +## Main 0.2.1 integration follow-up + +The subsequent fast-forward to main +`3cab8088d5bde474e990148f9c93d6c8ee422e9c` retained the upstream 0.2.1 +release metadata and sandbox review correction. Independent Codex reader +`01a19c55-5924-7231-b6e9-38b1436b18bf` read the complete Desktop manifest +and confirmed that its only upstream change is `version: 0.2.0 -> 0.2.1`. +Scripts, dependencies, entrypoint, build hooks and signing settings are unchanged. +The existing CI/security changes in the root manifest are separate from this +single-input decision. + +The reader independently recomputed all 1,706 fixed input digests, confirmed +43 unique roots, no duplicate inputs or approval keys, no missing or extra +keys, and exactly one mismatch. It returned `ALLOW_SINGLE_DIGEST_REPLACEMENT` +before the parent applied this explicit replacement: + +- Input: `apps/desktop/package.json`. +- Previous digest: `0432cb7fe2a9e637cdfdd1bb1368d5d238315f7c3b172338ab1f7f713706c52f`. +- Approved digest: `3da4c7d68ed543b5cdfca3042113db761c5bf9b9cbd9902be26fd55dad0c64cc`. +- Updated Office record: `3b6da167a1f7854a1e610ea484095b078160a38ceae6a0f35103eefae40f3bf9`. +- Updated fixed-order packaging aggregate: `47b7c19053d844277cd6a83d60846f6756a15e95a43e74ffb0c2aae2a484105b`. + +All other Office record fields and native digests remain byte-identical to the +preceding approved snapshot. The parent rechecked all 1,706 current digests +against the replacement before adoption. This reader performed static source +and digest verification, not a new build, native/toolchain or Electron run, +GUI check, artifact acceptance or hosted CI. The preceding runtime test results +remain historical evidence for their recorded inputs. The approval still covers +only Darwin arm64, `unsigned_dev`, ad-hoc, `provider=false` source prerequisites; +no public signing, notarization, final artifact, deployment or release is approved. diff --git a/native/office-local/reviewed-packaged-source-v1-review.md b/native/office-local/reviewed-packaged-source-v1-review.md index e323cc1eb..c7cdab3ad 100644 --- a/native/office-local/reviewed-packaged-source-v1-review.md +++ b/native/office-local/reviewed-packaged-source-v1-review.md @@ -1,13 +1,13 @@ # Scoped macOS Development Packaging Review -Latest source/runtime rebinding: [2026-10-29 macOS development packaging review](../../docs/release/macos-source-review-2026-10-29.md). +Latest source/runtime rebinding: [2026-11-02 macOS development packaging review](../../docs/release/macos-source-review-2026-11-02.md). The dated reviews below retain their historical evidence and scope. Status: independently reviewed source prerequisite; final artifact acceptance separate Date: 2026-10-13 -Current reviewed source: `37bbdf6de05c691fa6949543657d04e84dd17342`, comprising main `5bf2659bd11925b0e12fb459febcfde5c18688a6`, PR #537 `51f46a9ea1b128ac9d94243dd571f823f9a18fab` and compatibility correction `921c240b` +Historical reviewed source: `37bbdf6de05c691fa6949543657d04e84dd17342`, comprising main `5bf2659bd11925b0e12fb459febcfde5c18688a6`, PR #537 `51f46a9ea1b128ac9d94243dd571f823f9a18fab` and compatibility correction `921c240b` -## Current Dependency Compatibility Review +## Historical Dependency Compatibility Review Two independent Codex reviewers approved the exact proposed source bytes before the persistent record was changed: @@ -23,7 +23,7 @@ The approved replacements are: | `packages/daemon/package.json` | `9daa4ffc9dc8d1f0b30bb52ec44d5204db68739b7e7b97fa915862f466214201` | | `pnpm-lock.yaml` | `2b411405825673eee46fd1e099def6944744e830cb5cd6b3fbf28db30b49c799` | -The current fixed packaging aggregate is `21877f37a6bf4d6e5cce77d19deba032e53cb4b9b0f64dc23bcf0c8f2f037db2`. The unchanged Office native aggregate is `715c56bcc31a2f6fbf546e86c2a937702305675cd17e6f69136089f09a4b7c0f`. Both encode the fixed-order `[path, sha256]` pairs as JSON plus a newline. The original reader rejected the stale record before this explicit review; no build-time discovery, approval updater or guard bypass was added. +The historical fixed packaging aggregate is `21877f37a6bf4d6e5cce77d19deba032e53cb4b9b0f64dc23bcf0c8f2f037db2`. The unchanged Office native aggregate is `715c56bcc31a2f6fbf546e86c2a937702305675cd17e6f69136089f09a4b7c0f`. Both encode the fixed-order `[path, sha256]` pairs as JSON plus a newline. The original reader rejected the stale record before this explicit review; no build-time discovery, approval updater or guard bypass was added. The desktop license-material correction in PR #624 changes the fixed `pnpm-lock.yaml` input to `f8888021f9d124c5d5979857ab1fb8531f22a6c45095e23eed16147a72d59cad`. The change removes the legacy `exceljs -> unzipper -> binary -> buffers/chainsaw` notice gap and is covered by the distribution-material regression tests; the review record now binds that exact lockfile byte set. diff --git a/native/office-local/reviewed-packaged-source-v1.json b/native/office-local/reviewed-packaged-source-v1.json index 03fef1559..2ccec707b 100644 --- a/native/office-local/reviewed-packaged-source-v1.json +++ b/native/office-local/reviewed-packaged-source-v1.json @@ -23,20 +23,20 @@ "README.md": "458531f792e01734b360c7312652fddbc455fc95e8821bb1cf38dce7b8d0912b" }, "packagingSha256": { - "apps/desktop/scripts/packaged-gstack-resources.mjs": "f349b20d1c308aedefd55ef66bc5ad08c9590ef8395eb7b1e1945f8916d3c463", + "apps/desktop/scripts/packaged-gstack-resources.mjs": "407ad8bc5a38ed4de43679b6155aea2ce21f250325761399a38fa0e1392d7b10", "scripts/gstack/executable-bundle.mjs": "707a6b4ce35dec34ce73ff38681f2b857cbdc61c8fbd23952aca65dbe5df15c9", "scripts/gstack/upstream-lock.json": "6c666b7e9a40510404f565fe1599954126f82f472651c954496c663ec513d815", - "apps/desktop/main/entry.ts": "a4aef1637f00a20c4c2827d3761416e6a1931c8bfe37d793ef09deb6670d40f7", - "apps/desktop/main/qa-launch-profile.ts": "e56d515bc08160840673afda4d48304f80fa06dd30f2501239577ba7e0522860", + "apps/desktop/main/entry.ts": "a338765dc59416e7f1ea065d8a00c125dc889ec3affeeee05ac922eff0fd56d3", + "apps/desktop/main/qa-launch-profile.ts": "f7451f1949a6a0f577d378ef882581ff67ea432b7056da3fdad04cd103d92778", "apps/desktop/scripts/build-human-evaluation-helper.mjs": "5cab06f708c6dda15ca1dfa0f14f8f580bd9c069a28e7f20a81bdecb1131b287", - "apps/desktop/scripts/packaged-office-native-review.mjs": "edda4f97962e6d8a042436d10af0f2c43fdfa7b57a57c3c116961bc502cdfb42", - "apps/desktop/scripts/packaged-office-native-toolchain.mjs": "23c9eac5fe2892445cd01703413f9a97333e66f9102144754b7046299035ee2a", + "apps/desktop/scripts/packaged-office-native-review.mjs": "437016ccf8471b4c1c6720558d07145920d677b212ac511ba377dd8d0e7d99c6", + "apps/desktop/scripts/packaged-office-native-toolchain.mjs": "f1f063f77e8b0eb7fa8f864a992abaf851c20bd1bd643b428d5fae6bd766e674", "apps/desktop/scripts/after-sign.mjs": "c8fe94876d1be77bdd460896002ff57ae6b2ec98c95f2b67814a54afc82af722", "apps/desktop/scripts/engine-supervisor-package.mjs": "b2726052967bbd0735fe4c5371a566d519f9dc8eb1ab59b3de75e115570e82e7", "LICENSE": "b369b7547a372d61515d3ac92c231582e31043cb0bc0e62e39724a00cf568830", "apps/desktop/main/resource-paths.ts": "4170b6d41abb8bda7e75376d23f4691b1271b9dfc6a72a80543a11f331f12ae1", - "apps/desktop/package.json": "db982dec4a0c4a5fef3e54617e4f54a6d936acd74c269073449cd4cb0a1a73f0", - "apps/desktop/scripts/build-packaged-daemon.mjs": "6324dc45fdb7916438d047e8936c1883a84929d8f027646a91b33f3dabeb6925", + "apps/desktop/package.json": "3da4c7d68ed543b5cdfca3042113db761c5bf9b9cbd9902be26fd55dad0c64cc", + "apps/desktop/scripts/build-packaged-daemon.mjs": "78e9f125f17aa76d72d1144ef17b96317d365017de5ab59591ab51e5383e94e2", "apps/desktop/scripts/build-packaged-office-native.mjs": "5977469bfaadeefa317191d04e944134426b2ed828713989e71ba77cd6a407dc", "apps/desktop/scripts/build-restricted-command-helper.mjs": "4e3841ce4a72e5dc218b14ab87f03d67f642a50bdb0624892b951ecfd60617f2", "apps/desktop/scripts/cua-driver-binary-identity.mjs": "9af837af82fc5ef3c9265cea3c08d6e2f01076c160649080a2bbb59179e75b30", @@ -44,7 +44,7 @@ "apps/desktop/scripts/cua-driver-electron-builder.config.mjs": "4e39582ebb2dae36d2cb83f76e878b4616e67839ca6467daf9b990de40f337dd", "apps/desktop/scripts/cua-driver-electron-package.mjs": "1bf1a0c06ea8598eac63c79e14638db10a1fea7317003da8bca916f72910e943", "apps/desktop/scripts/cua-driver-owned-process.mjs": "95594adc3b9ee30d04d18db7f1d3e734d48ff9545034112e200986a5a5af0908", - "apps/desktop/scripts/cua-driver-package-atomic-publish.mjs": "101938e9e66564f927e7774ad601aa30907fdc223b3867dfa9b18719841ab574", + "apps/desktop/scripts/cua-driver-package-atomic-publish.mjs": "a1d991ac33b8324b3cc34d92dd9114352604c9eb8a6b8334a159eefc16856090", "apps/desktop/scripts/cua-driver-package-host-authority.mjs": "b52765c25d5a4238fb6f941b76350db468810b14e8f83c1b9182d820ae6426e4", "apps/desktop/scripts/cua-driver-package-runtime-policy.mjs": "280423365330858becd7fd1a53e6db21c3c44d8cf60bc231864d84c6293ca385", "apps/desktop/scripts/cua-driver-package-staging-contract.mjs": "479e9beccad490c36b7699ab528ef39c39b66710fffdf48652ead171cbd42406", @@ -54,12 +54,12 @@ "apps/desktop/scripts/cua-driver-package-staging.mjs": "bd8e2c018691f57f8341343094de1295fc5024293370682ddcba3288912ad2b7", "apps/desktop/scripts/cua-driver-signing/after-pack.mjs": "46dfa66bf3582d6654f6c39c46e98d45f1425dada974447a7f7aaff64e8197cb", "apps/desktop/scripts/cua-driver-signing/after-sign.mjs": "14b9c771fe8e1b084216f6fe760ec0d20500490ad257f97efc312847052e48e4", - "apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs": "456edeb4adb56c332468451f34800501ef2235e5cbcfd01064bc93c7f4db6abb", + "apps/desktop/scripts/cua-driver-signing/artifact-build-completed.mjs": "0334a0a609fbc132b013598a158fd0ea229fea457e3319af4846d0e941f5bafa", "apps/desktop/scripts/cua-driver-signing/darwin-platform-signer.mjs": "c4fcb9ea0c468bb5d8cc08a772e26467af414f5365b93aecdd1f8ecc55df3538", - "apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs": "25fd5d0612a97f738f65121401f3171c49c8f6562e4cb8d44a86a7934fd6c3e3", + "apps/desktop/scripts/cua-driver-signing/dmg-attachment-custody.mjs": "bdba1977e0de2d1b06bae39ee466aacf90c2b85e460da73af990c1b2345d91b3", "apps/desktop/scripts/cua-driver-signing/dmg-input-snapshot.mjs": "db6339159cf33b6acb7ed56ef669a52f331c279ef7bb8a4a55bf090684bf0c6e", "apps/desktop/scripts/cua-driver-signing/electron-builder-mac-sign.mjs": "bd0d776720c126ba65dce6f3c6a4996353f856befc2c3104f286ac8ac28a52b6", - "apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs": "26c78d05923bab03edb1b0f4b5a73e8af7ebde6c1fd18f6ebb698c073633129d", + "apps/desktop/scripts/cua-driver-signing/final-artifact-extractor.mjs": "b75fbb31c67da52df438839e06899490e68dfffa517ac5aad244d97e21bfcc62", "apps/desktop/scripts/cua-driver-signing/fixture-signer.mjs": "382224e246f6b98123c54f732fe8e2de206849a00c66111c349761d726feb381", "apps/desktop/scripts/cua-driver-signing/hooks.mjs": "530c274d2f52bf37caccbabd88347a0f9e2b8daffa29b44a090f427b5e43621a", "apps/desktop/scripts/cua-driver-signing/provider-layout.mjs": "8b7da06cfa6ee17631b5b68ecff9bb39ea8ed696768654be5333705dcea67f34", @@ -70,15 +70,15 @@ "apps/desktop/scripts/macos-development-signing.mjs": "336b21271cbb786a81bdaf3723a1d8d1a7c84b6e53cc866280b02408a0cebd6d", "apps/desktop/scripts/native/cua-driver-package-atomic-publish.cc": "5ae38dcacd8cb30f0083cff829d1e6e6bf5527b778be002e1d3605caf3d4d197", "apps/desktop/scripts/native/human-evaluation-darwin.swift": "1c54b9fe8efc77148359cfcfa1d0ac6eb9203f8352b82e62c179cbbe0932ae89", - "apps/desktop/scripts/package-macos-with-office.mjs": "53f9eb6a9b83429893d0a85c8cc982ebc4a9673e02e0d0675677db7b653517bd", + "apps/desktop/scripts/package-macos-with-office.mjs": "6ec6da5595bcd286674b3fbfc0912798a9b454f631bd07060ba81f2f7f25bd8b", "apps/desktop/scripts/packaged-office-daemon-source-inputs.mjs": "11095e01e85a0af423dd04ec8a2f49ff4242d7f53a955ce0718f88ced528c9fd", "apps/desktop/scripts/packaged-codesign-review-publication.mjs": "3cae0a43b8a369361621f087b25c3febf87c3ec2466e39c7df5774cb81e6627f", - "apps/desktop/scripts/packaged-office-native-asar-checks.mjs": "4382194ce4b4ac20b93879550bbeb91a880546113c675dcba50373930d194f03", + "apps/desktop/scripts/packaged-office-native-asar-checks.mjs": "d8dbefb32de9b6a62efe13da8d02aafd5c47edbfe4094d1041bc477bd011712d", "apps/desktop/scripts/packaged-office-native-package-checks.mjs": "875532035bb38ffbd5a725af5e39d11ea818f679f420dbf551459e1119877467", - "apps/desktop/scripts/packaged-office-native-review-inputs.mjs": "fa84f450f7380ee021c6c6e312e73bf76616413ee6d47689efcdef8a656fce02", + "apps/desktop/scripts/packaged-office-native-review-inputs.mjs": "7855899507a5a9154100726dc96a724afeb93d5f0c8294f1af4d48ca00448eb3", "apps/desktop/scripts/packaged-office-signing-inputs.mjs": "61080aa53c544eda63c475e870158bf9aba29ba94c0b4f8ba9c12dd6c0b3e3a3", "apps/desktop/shared/cua-driver-artifact-layout.mjs": "83c2a27a6d7bc4d8805588901d311bab1ee19fbc2de6475cf1957948a572d72a", - "apps/desktop/scripts/packaged-human-qa-lane.mjs": "5d917ac8333b153ecb334f2c3b4f3f8564bc31640a85550a4554ab2f2dc4b6ab", + "apps/desktop/scripts/packaged-human-qa-lane.mjs": "bf7a485f0354c3e1e5c0b5f5d3491648e591a53fb98d94d73e36ebf5ae72f2cb", "apps/desktop/shared/cua-driver-package-authority-contract.mjs": "b37130300143ef31a42e6601526f664098fffdc659943a4dee2d9b727a332003", "apps/desktop/shared/cua-driver-provider-lock-contract.mjs": "66e5058de139c996bd91c308efa35ae9640acbe41b5f5bb3d37a394a1e5eeca8", "native/office-local/scripts/build.mjs": "5e4cd3082c689439d5b8bd012c0d44a3e8e042718305118e6e895491c4cfc1ef", @@ -110,19 +110,19 @@ "packages/browser-host/src/runtime-descriptor.ts": "fb7a006dd3f2cecc0d65d1003dcdd4c88c58ff3cce9524a0acc1d170d02ca122", "packages/browser-host/src/windows-acl.ts": "2ceb11e080479416d8ce1762c4583d7f93f574e00f73c08c282bd17b75edbffd", "packages/contracts/package.json": "cff000b571b3a03a5d249b9c3397d41e261f3376d1fe6de50a52d090940dec27", - "packages/contracts/src/channel-command-goal.ts": "c7de835cede419a32de28737b9303836d0ad7960a4b8fb9fbe2f1e0daea3de4e", - "packages/contracts/src/channel-conversation-projection.ts": "653d31f61b7f52084c7528e5213a46ae107b3ce6052d9fa80d4e34529f31b7fa", - "packages/contracts/src/channel-operation-bindings.ts": "22facf9079ce349dbf3c2a8a026e9a97d791ff3b46fc3e03a844674ecaa0d25b", + "packages/contracts/src/channel-command-goal.ts": "ea8a19933b4108376c14c93ea9ae4022e52ba2c6854b6fff7ac278a5d6580db8", + "packages/contracts/src/channel-conversation-projection.ts": "101c4fa7192a379e607bb2a208308e50c4edecf93339d2bbbccb05ad2b9f2515", + "packages/contracts/src/channel-operation-bindings.ts": "bd05499a759671fd047e7cc06a5354f99981e928304b465732edc0d45af4388b", "packages/contracts/src/chrome-browser.ts": "d6461f1a29ce09e331c423b58919fedbd924e1774af6674acbdcf5dc53dde41f", "packages/contracts/src/committed-run-narration.ts": "5bbb2f6ed039e819653a3acd497b0fabeb3cf9cd8e3b23e49acb6bcc094b0be0", "packages/contracts/src/computer-use-validation.ts": "0b1ebfa0266a3e76307ca841ff2038d8f3362abbe91082be7fd0f3057f96891c", "packages/contracts/src/computer-use.ts": "7094f39c1b7e759b7e6b06aded32e0b678ba7e9bd5613856f0e33bc8a212a3bc", - "packages/contracts/src/conversation-continuity-validation.ts": "a87f8c78c58852dbdfbbf450a2ab1d63bd4fb4776e66db73be7955b2d61ace72", - "packages/contracts/src/conversation-continuity.ts": "24625243e25e26b5b8d99e505098141df8a8b833796a18dbc64d9bfe2cb44d1c", + "packages/contracts/src/conversation-continuity-validation.ts": "5071b28fe44c3e22da2fb5d011d4a9305c149e0d5f18c8af740b9fe3bc75c4b4", + "packages/contracts/src/conversation-continuity.ts": "3ba0725493b11db5b32a521743a722a18847f24941231a374e6c6ed6cf3d73f3", "packages/contracts/src/conversation-model-binding.ts": "f7d0b17a496efd0b93b83210684bf72ee057a7b03510cee94e297977a17af5fe", "packages/contracts/src/cua-driver-bundled.ts": "12157142e57a19248beb09312b813de3f8cdf842056df6645cbd653b7e74c68f", - "packages/contracts/src/harness.ts": "723f6cf30bfb75f4e41d0b8c264f2205a93070f45fe00523cd483de9fd650e42", - "packages/contracts/src/harness-diagnostics.ts": "0fb33c4686bd67c76a1b1dae51e7843ba32123e1bfa1e1703e21b82123130b16", + "packages/contracts/src/harness.ts": "8a935abd7710f686d76f9eec3a5e96e0c498d9c49897a9fade0228db176efac7", + "packages/contracts/src/harness-diagnostics.ts": "6db8ae52fdaf4ab07ead9c34bfdc78c79092545d459bdf0751a7f3ecb1a554b5", "packages/contracts/src/harness-evaluation-qa.ts": "246bf2463f9c48e4a06fcbfcb640360ea5b460e7cef185c5c5bfc20eb6fe9bd5", "packages/contracts/src/harness-human-evaluation-read.ts": "c4ceaf732a0752bb15059de15ddd12d2f1939c1946598edb34649ccffce05b9f", "packages/contracts/src/harness-human-evaluation.ts": "63a6647aa693933128e14763350c8aab19ba9c954716f01a7aa2fdd7fd13b241", @@ -130,28 +130,28 @@ "packages/contracts/src/host-capability-results.ts": "a5e08074431e844369cc95ca2c4b0bf1127c2d88d28f2440b5cbfdeec948e28a", "packages/contracts/src/host-command-journal.ts": "59354eed21fe66d631aea3721410319b84aa9209ebee49acd1cca779315c6a30", "packages/contracts/src/host-open-receipt.ts": "3acf0e1a568ba36c07503b2f8eb1217a607383be52fcf2a3e1866a05a4624c7f", - "packages/contracts/src/index.ts": "47291be3216668a1edf4defbca26ab986ac540ea23d98a8abfc083fbed0ea513", + "packages/contracts/src/index.ts": "0e9e1d000d1e02fede3e71eb206a7c93dd8d7e51b456878442b1a9a9561e9d45", "packages/contracts/src/locked-computer-control.ts": "e3bc00c8c6a436e94bdab21a8b1b9457769a139e053c2571a34c533ff66071bf", "packages/contracts/src/media-execution-evidence.ts": "5ffe05c72766dfbc8f2773306fc8da007b613016befd6ca9d26c0c27221ba253", "packages/contracts/src/office-documents.ts": "5d965ca90a5158436e6bd71821326c12da96f6a99fbdd54d7267b2c28c99e1d6", "packages/contracts/src/provider-diagnostics.ts": "845eaa50acfe04c417509da678db60841c25b641fe5350c0e6b3cd8d5f7c8c40", "packages/contracts/src/remote-access.ts": "18d4d608f57c56a13b9ee243a801a7251ee2eca709806f3a8f1f59dec807e374", "packages/contracts/src/run-compaction-checkpoints.ts": "5b399e5766608ac49404cd01be70dcd41939836e13c14f1827d27b6d78d30923", - "packages/contracts/src/run-event-validation.ts": "98c8f5c735f986db9b72b677384060951b43359593cbd25f2c9c0f2c4cef6dc2", + "packages/contracts/src/run-event-validation.ts": "87a3206785df94660deb57a07213dd53f32e766dd3dc1234098f875ad103c98d", "packages/contracts/src/threads.ts": "7fcee3a324d4c2c15c742f89c79f69337169374d76c2bdab0ea5ac125a0b3797", "packages/contracts/src/user-input-event-position.ts": "fde7800d9134241b52fc5d0100fd9a1c3d65bb2672f33afc3fa1459fae751902", - "packages/contracts/src/user-input-transcript.ts": "60e8e23955d5289421c1fb58634d3a841c5a1b0d50e2d3320302ee2eadca4ad2", - "packages/contracts/src/user-input-validation.ts": "4d2be9d5b40172e827e5684b918d0d652024f6d87d5a54369e4a0ab2b6e84083", - "packages/contracts/src/user-input.ts": "72c3f00b382ae9093a02525eeecbd64a18aa400fb00a3e3a3ec467e4503fd714", - "packages/contracts/src/workflows.ts": "eb16e18c853c29d1cafa38ac9d772d3322cc835e2309c91d8da76a96de722627", + "packages/contracts/src/user-input-transcript.ts": "95ac508cf94d07e62dd60fab4dd5436c886914d538124bf4d104e9cacf68091a", + "packages/contracts/src/user-input-validation.ts": "3e40caac3ca22d8b0e95e8c9fa865a7b844ebd82eb3b8725111f0cee089a3541", + "packages/contracts/src/user-input.ts": "4e05ec040df17d64afd23e150d182285cc8ed5d15310de7a9f25eb5528394ac0", + "packages/contracts/src/workflows.ts": "8dbb63dbab4e1e4b69e4a1aa89398b5ea1b12efe737aa505dbd283c653bf595f", "packages/contracts/src/workspace-changes.ts": "efb0337fe87c237e4cbc4df3cd05b2e5edd99e914177362c6238bdaec3e4c862", "packages/contracts/src/workspace-snapshots.ts": "a4019ac6f81fc220ce51c0663bc9502089cf87acffc2c3413b5b941d25aa2a92", - "packages/core/package.json": "cd92f92dabed7d04cf9f468dd05e065205e63b2ad3c512030983b25ec98323de", + "packages/core/package.json": "c3217a2905bd03c7e7063329fe6a94457c2c9ee274a5baba092345bb89fbbb51", "packages/core/src/ai-council-view.ts": "ad8bbe828b3ac039a4bd0ac57157e7d0f9a16a03baa6cd58ee5b3f6af3fb80d0", "packages/core/src/approval-checkpoints.ts": "c25f2d027216a8d64d29559a937821b24b0b8a45e822837163640e99482148cf", "packages/core/src/approval-policy.ts": "562f3a84328de71010ae4e5c1144ac4f9a0df5767b36e7a68ff97f26e2f373c1", "packages/core/src/approval-postcommit-types.ts": "2bee31b9f1d960e13b47b977a9c1db38ad85da00bc14d8ce5170274bea2edffc", - "packages/core/src/approval-types.ts": "53040b9295995ce26addbad8be8221d4f83710320f16b948816ebf6fb8971922", + "packages/core/src/approval-types.ts": "d19f3be952d91592f6068488da0bf206a78e1a30d97a6056f27bd97ef76f9a32", "packages/core/src/automation-context-budget.ts": "f7dd549c19551e3ebed75fca38b19004f8fdefbb9d1be5422ed10e189d521822", "packages/core/src/automation-drafts.ts": "4cc9a8974f3d8f432929d3aa626cc12f82c0f219425277f9411f361d89fe6b33", "packages/core/src/automation-recurrence.ts": "54ec4e91542310c6c5900ce5529e288c345f69deac6509e40dbde7ac841d0ae7", @@ -159,9 +159,9 @@ "packages/core/src/automation-types.ts": "0fd1565dc8d2e3b0e89bcfa5b8817b6790bb19183f67a1ac36f7e4dfb27661c3", "packages/core/src/builtin-extensions.ts": "2d2ea5b1ded294e25784b875f411bf278b12c72d5414f25cf653989291071007", "packages/core/src/builtin-skill-packs.ts": "1f7571bc97b3b66d10bb08963f15d03b705fb8203b1551bcb68d975fdd1214ea", - "packages/core/src/bundled-skill-source.ts": "089d4c61442e8aa1cd1f50c67731ae99453da50fdaa2254d56cb05e647de5f90", + "packages/core/src/bundled-skill-source.ts": "897f55f83b565ce7e736ffd2f65d7b5f4426202344133c44d8a59050722b53d6", "packages/core/src/bundled-skill-browser.ts": "744fb0389b7aa84ddd81da535cb71cd4a367012f55a66c140f066eee37874a34", - "packages/core/src/candidate-router.ts": "a823e9dec56c1d9d8a7495bb7e0f2a4a61bad0e89fb057f6579fba6a409f6d2a", + "packages/core/src/candidate-router.ts": "c39c02c3464a546d7b3d0fffa3e3af29924b659d1ac997f6f2865ff333155e9e", "packages/core/src/channel-evidence-summary.ts": "9e4d46b2d8439e655cdbdcc0e5fc83135e4e8830c7ffb44dd8e1bc386c27d564", "packages/core/src/channel-runtime-types.ts": "f17ad4ac804b9564259d29371e61672a258552b9425f98e6ab63ff80db1b34d4", "packages/core/src/channel-team-attention.ts": "808f324d529c72438c6ff8d01390bd2b239d9762b603c246ba05fd98f38e79c9", @@ -177,22 +177,22 @@ "packages/core/src/chrome-browser-types.ts": "cd978295de1ccb73343ff47049df29a0b1b6ca37285435af4fa6e44ed5416a17", "packages/core/src/composer-slash-intents.ts": "2f1bd0dcb569ec33b9b3f837ba5d6af917d1a3154cdc6f65483b0ef447306e6c", "packages/core/src/conversation-accepted-input-types.ts": "a24902157744d6b9660abd45143b6080b62b4c42bdda30838a8650347f71f9f2", - "packages/core/src/conversation-context-store-types.ts": "aca8d29a917efb2b96b73d04f4d32b543dbc7cb9ec6ed3c90de2139740feab9f", + "packages/core/src/conversation-context-store-types.ts": "121acd4c54eb82c99b3003d903ad513d0bcf25667510ae759ef7075fca93a0ab", "packages/core/src/conversation-item-run-event-projection.ts": "a7be593e9a19527d4468b42ecc54952a74159822e810942f409e63cd1b82456c", "packages/core/src/conversation-item-store-types.ts": "09d2f59daf4b70f638e58ec7e622d4d4c530413d8afe136f7bdcee696e9f2d48", "packages/core/src/conversation-narration-owner.ts": "b6bb57674ad0c09fdc6601c578b9eefd61b3a40d2ea420b2f6440e596489c340", "packages/core/src/conversation-narration-segments.ts": "02bdd9c28eaffb08ba18555bbeb05aaa84e01444cce243d39396944039285ac3", "packages/core/src/conversation-prepared-input-types.ts": "03d5dd36ebdcfaa9dc1bdd7280fe31700d69d6708c4dd0dcebb46d63e539aa08", "packages/core/src/conversation-revision-types.ts": "ed75b2d47ed07e784bd6337f804927723969113fc7f68770ee398b12e36ca0e4", - "packages/core/src/conversation-thread-topology-types.ts": "e3ce6cf2858191fad2914ec45cd895c4165b470e40efedcb56fc25288d0dd7fa", - "packages/core/src/conversation-turn-ingest-types.ts": "3980321cb7791360849d2757c12ace90aa29a7cee38667a0d9f989dcf75d8b76", + "packages/core/src/conversation-thread-topology-types.ts": "12a398c1cf97695121868aafd8feffd6d1edf4ede157c2318e782e22a289e5ee", + "packages/core/src/conversation-turn-ingest-types.ts": "252cbbb19cb26481427ab4418a23c4674e0ad42345d8d416a2838005c803c2cf", "packages/core/src/cua-driver-activation-types.ts": "dd51d797a034ee881979cbc6ff4151adb8772168f0d8cd511faf67aba70ebfd9", "packages/core/src/effective-skill-registry.ts": "accfd248ca02376bac35795b8be32aa691182f036b3db1531e72551335cd9612", "packages/core/src/effective-skills.ts": "d4fd8402e33a5bbb60f9fe2da6dcc2cd163d1b9b1f366ca1abfe2f1ab41eaeda", "packages/core/src/exec/shell-snapshot-types.ts": "62ee72378bb1a3cf0e83eca5181b8959492875728780f3a85d6ccc8f3012bfe5", "packages/core/src/exec/unified-exec-types.ts": "588627b253db2f61bf1b501a721f9a6f2faf97c5564a00b6a92319faaa52b188", "packages/core/src/external-work-actions.ts": "2e43e9e3ea084ba3f012cebc64823f83c4581358a5cbcf76c981a9b695973d5a", - "packages/core/src/external-work-ci-triage.ts": "fb7cc098126d28c47a48276c9eb9404a852611afb648311395e2fbab1515ab78", + "packages/core/src/external-work-ci-triage.ts": "c4427eb289404cb87a6678dc72e000baa17e8eae61c01bf591e2ced2aefbe73c", "packages/core/src/external-work-scheduler.ts": "a25e974d57db23be49af01ac3dd9970f583d9681be587acfa7662309b02bfb35", "packages/core/src/external-work-types.ts": "4b488206efd41961ce2031d16808e46c4ccbdb2d574ea3bf81a069ef7940c019", "packages/core/src/external-work.ts": "1a12627874a1a24f362b26ffac51d27b4e82e2dc9d53445695b8ead44252f40e", @@ -202,7 +202,7 @@ "packages/core/src/harness/continuations.ts": "25273686058141e7bda946f34ccd389c4cb85f265eed67a4d1bd4a0f25685669", "packages/core/src/harness/delegations.ts": "43f8bf0f7779589c6bc2708a9489a90261ee3fc81bc8e926b03f5454b5872c61", "packages/core/src/harness/evaluation-json.ts": "a3d738a4bc6399a2536010fc10064601dc0e9ada5fd58be8c2811b5579ea73da", - "packages/core/src/harness/evaluation-qa.ts": "09cf9b88970b8a8e59cc949b3d3c73aea03ce9451cbd81fc11605e478679d803", + "packages/core/src/harness/evaluation-qa.ts": "b48689181a87ab7a832334f671cb54e0c81ff084a20a75bbff40db71108d76f6", "packages/core/src/harness/human-evaluation-read.ts": "7d4569d85988377a786e97307bb804a55474dde1b15852b19ec96acec0053fae", "packages/core/src/harness/human-evaluation.ts": "8ae699b733381576588751aa61a5345c7b4eba8a33e25b908c93ee2ebf0f8c29", "packages/core/src/harness/evaluation-rule-grader.ts": "e2a0e9eeda75bc46b54dac88bfc585b01a0bd0f5188bfee6aee8c77bf89295db", @@ -210,7 +210,7 @@ "packages/core/src/harness/evaluations.ts": "9b26b7f3099b4a800b10283c2a9a99ef3d5f52f8316078252789f12994da50e2", "packages/core/src/harness/store-ports.ts": "722daa6ff81e4882d58a49035e2a38a83a8eb0ada90946348c3aeec1df38d44d", "packages/core/src/harness/tool-executions.ts": "fca638f1489ba4619cc94ed74eae8befdea1dd1f206adb516544dd890d2e1e23", - "packages/core/src/host-attempt-completion-types.ts": "7d41df40d8d40d3594edead8aeeaa63b7048f710f1a681906e3c5395c4b46431", + "packages/core/src/host-attempt-completion-types.ts": "982c997cf197b368d9016ebda57f276d85c6151c2cbeb2d4e875a6f809153a11", "packages/core/src/host-command-journal-types.ts": "f0f5a8a2e6dab57e3b3f69f96ac0889ac5c77439841df9f4f9ae6617266a3bf0", "packages/core/src/host-compaction-resource-types.ts": "0d2ace13d6aa2d3b6f9b05827cd66c7276e7832e46a350a47d597ccd2a8fc471", "packages/core/src/host-media-operation-types.ts": "1f695be05267f26acfbda19972707339250e5736c104e544dcae198310271663", @@ -223,7 +223,7 @@ "packages/core/src/host-office-local-terminal-types.ts": "61ef82106687637937cef7911752235ce3fe7a8a2a06978fdd2000b903339077", "packages/core/src/host-office-local-types.ts": "c5d83abc54977a59f7532172534eac2b8f23ea36ae52ebd3df5b6a8edd090d1d", "packages/core/src/host-operation-journal-types.ts": "f76c8627f9181b890fc8a4b4dd887b3b5ac550c5890245cbeadf87ecffe8992b", - "packages/core/src/index.ts": "81daca91313330cc6b2f3340469a94e1840d4fd5741e47a14228cc2b82f3bda6", + "packages/core/src/index.ts": "e4413c1d8db35a9033acae9f05e367d61d1a29b8074fe906c89776877703d3c8", "packages/core/src/input-materialization-types.ts": "204eafa93eed24d1c618ed2e575c0b02332ae6bf17d7e752ebdc128ab2df4c62", "packages/core/src/managed-official-skill-source-types.ts": "959891701aa050041a46858cba26a5628bb4ff7b2e614f35b1e92ea0a8d09d98", "packages/core/src/managed-official-skill-source.ts": "9c12253e12c3cbe46f832987be40659e356bb44abfcb0faaaf064405ac2f8bf6", @@ -238,9 +238,9 @@ "packages/core/src/memory-markdown-export.ts": "cc0c35db0cd43b8ac25bc6f532a88a26d9923de634f73d08ec636ddd6c1261aa", "packages/core/src/memory-markdown-import.ts": "a884f0b67ace04b24844c37111e73aeafb4f3424fc25a619c126f2490c8155c6", "packages/core/src/memory-review-policy.ts": "be36f50653d04168f0af732cbba7190ba37d74d71565c4995908ec96d4a2f694", - "packages/core/src/memory-types.ts": "54f0c615ae3146347497f540620a77991946edca094d94d5fc69c300ec61021b", + "packages/core/src/memory-types.ts": "af3b94dfbf5fd104faec38c8fbc4cc430fc109aa28feb75bd7cf237764333fa5", "packages/core/src/normal-conversation-fork-types.ts": "a3ee74a31835d5fe2034ca6dc9caea75778b4726ec1fdee647a392a5a0bbdfad", - "packages/core/src/normal-terminal-report-basis.ts": "35d13bab76efcf74f2b423bedfebd2cfc34953222b7eacd1ee798e2e242e5c3c", + "packages/core/src/normal-terminal-report-basis.ts": "e46d178d890845a87098bf504b4166c41f508d47f6fbdb54e9da4e1862086036", "packages/core/src/normal-terminal-tool-barrier.ts": "3df07a529833f86c225d2fde9c53b314ff10d1019843c1dc584905d99c66162c", "packages/core/src/original-host-process-entry.ts": "61a466d2101233c185598c3da1a379b5be88fbfe98a518ef8a1c57d34147392a", "packages/core/src/original-office-committed-pair-v2.ts": "6e9bd3be4d84789d85f3fe67995c3661124a8c69a54f2083576f56563d0fc00e", @@ -253,12 +253,12 @@ "packages/core/src/patch/render-patch-preview.ts": "070dba624d55c1a4b0c5c950c092c09b863036572ac37a09067448accc316fe5", "packages/core/src/plugin-manager-types.ts": "54fcd240855021b3718db3c1a488a20bf55632fe6af03cf4901fd2ed0d2a6101", "packages/core/src/preview-types.ts": "c900c68c6cee6dd613ab661f57419389034526ae31ccee9a07f1a0de8d388616", - "packages/core/src/procedure-observation-job.ts": "8dc20fa26e5b558918adcc82beb9b523cd675416302a66b9539e49abfc2e85a8", + "packages/core/src/procedure-observation-job.ts": "d355503100a7517790f52e8774e8425acc553356b507d990db2eddba66d8b6ed", "packages/core/src/procedure-observation-types.ts": "34e638f6e29494ab663b4df94f2820a1938c4bc7784abe021cded6e17918f6ad", "packages/core/src/provider-adapters.ts": "9d118bd05f21843cacf661dd8e548c638aabfde30da890a9bcaa32a2cd6a7f0a", "packages/core/src/provider-generation-models.ts": "56a4a6b524c72e44cf47f25c023e6eb6916b78833d140e4b89cf7b1feb38f123", "packages/core/src/provider-media-normalization.ts": "c9f3988e8af8436b51bc473ed7207f9dbf9ee412421342104154a1d51d94a6d9", - "packages/core/src/provider-model-capabilities.ts": "c4e8913e6f2376a20570bcaf638ee6f521da868b063b1ab5b6fc574a23b7d701", + "packages/core/src/provider-model-capabilities.ts": "990ecb3d9eb5965fdc1c7c7c7032d0133699d708e622280e7741022239006ee6", "packages/core/src/provider-model-catalog.generated.ts": "1efa56462207a1f38f393846596abaa092aa7f2a7270b6912ed1b18d85b86162", "packages/core/src/provider-model-catalog.ts": "1cb12cb75c7a4723fb28f174141bec8d313c7e44e3b037e1a9ad92f80b749674", "packages/core/src/provider-runtime-availability-types.ts": "9b417bb1c4077afdc037ce225322eae44b104f52fa86dfc4595b9a3facc013ce", @@ -269,13 +269,13 @@ "packages/core/src/redaction.ts": "50bbabd9024f2072ed7538d765e439ba2217af66d80f8eb6623056fba2bbfc47", "packages/core/src/remote-access-types.ts": "bdb8b4a83e15747621474bf3af4e48d7732d7f2255143a5286ab578beb515376", "packages/core/src/run-progress-checkpoint-lineage.ts": "ad91c9645eac0ed76acf30ea093edab35c68d94d6e43ce31b56d1bb0bb8a1ab4", - "packages/core/src/run-progress-checkpoint-types.ts": "8903feaccf278bff0540a1423f90c57bdc8b75de290f38ed1a2b116ccc98961f", - "packages/core/src/run-types.ts": "9f8d39e7a6f210776efabaceec142d7263aec452290343877d0a95a874626a13", + "packages/core/src/run-progress-checkpoint-types.ts": "eba779a0a53270896ec86ed9249eba657e7d1365944f02dd6b8d4eb85d1254b4", + "packages/core/src/run-types.ts": "11e58e68d13e1311e2278560b408bb67f1a81d8a0174a3648f6919cd4b7d34bf", "packages/core/src/runtime-paths.ts": "bafa17caff54db8aa1e970ac81c13e6ab35a63f39a524a43db70996e20e18d86", "packages/core/src/runtime-permissions.ts": "83842f409259f493e1664adbe812a1a167e3690c5ae5efce39ced42656f7da13", - "packages/core/src/runtime-supervisor-types.ts": "8e75c416ac13c5678a0768480729a94214c66ace885a5841cbb8dd5361923045", - "packages/core/src/runtime-trace-projection.ts": "a7a9e6ae600653f006b1090a24e1a6d930fd0c126eeea48020a3d8bcac3cd594", - "packages/core/src/runtime-trace-types.ts": "ef6b4ca9274dc3d02e0c0ab7950cdda157c594ad04185563509c829a0808ffce", + "packages/core/src/runtime-supervisor-types.ts": "ef0a4bd6d7c0f5c4933a833963c55de9d2a1ed88ff0380c3c5ae83c8ff069dcd", + "packages/core/src/runtime-trace-projection.ts": "53a654cbe3ad94951f00da47c69e89430d117463679e7200dc9849edef5dca2f", + "packages/core/src/runtime-trace-types.ts": "29a0941cd7290a5d8c317235889cbab8f8657cd9f8c15147deebb09594e71e6b", "packages/core/src/sandbox-types.ts": "ee170ccce240405445754e6985775d8da7a811c5221ec509853a447bd5ba3d67", "packages/core/src/sandbox/execution-policy-paths.ts": "7b2125f47fb62dee465353962962b55fcdf53bb4d7c3f91b237bfdf7a9583e9a", "packages/core/src/sandbox/execution-policy-types.ts": "150481d9f827a879a7da7911972f5414af9cc478953decff642cd140b6200cd7", @@ -283,7 +283,7 @@ "packages/core/src/sandbox/execution-policy.ts": "f5259ccddb0db2f4c4dd3af2cb29505f8f8f891d5d8a287630d94e60013e0064", "packages/core/src/secrets.ts": "2fe44c02223322d45fe04fba9f94df252e293083f6f6253c682e8ecae009cab1", "packages/core/src/session-permissions.ts": "1050bf79f14ab1f5b2b39b5e393e8c236abdd7718efddab8b73220279a592c5e", - "packages/core/src/skill-candidate-types.ts": "95652f00e1aa67ddac83275a42936055b548c09ae5de4ca0bb1ff5af9de63428", + "packages/core/src/skill-candidate-types.ts": "e225ded1c1a05b90780f8568ee17ca5b29fa65c1c0020cd960ca96acb41582d3", "packages/core/src/skill-health.ts": "2780fdfa6a45a50a09940d7e5ff91f44a7887907efcbad90cddc927d2d645aef", "packages/core/src/skill-optimization-preview.ts": "75e0ebf8a5b5989a11fcab98793df363b57eeb4490a0989a9b5ce2d9023264ac", "packages/core/src/skill-regression-harness.ts": "756848a3cab75747a2e22bee717831dd704539dae47be956f3f6e4e436980e57", @@ -292,27 +292,27 @@ "packages/core/src/skill-source-health.ts": "771633af2b334a0ff0188a9f69f0884de782d2ec036250a59d2b7fbf1b410d81", "packages/core/src/skill-source-runtime.ts": "615f2d7089958e1bdf1e21c997b06d60b47980375f4c66f07098df884c38245a", "packages/core/src/skill-source-selection.ts": "948b8840517f34c7bdd358f13e1f23e83a8f36cfa75deba7794c1575c9f1d5ae", - "packages/core/src/skill-source-sync.ts": "be94771f9b758f292434685c7a4386b9c789122ee6d2350121a4ea4bb3494f10", + "packages/core/src/skill-source-sync.ts": "f1da2c0dfc6673b211aa758fdef38899876966d66bd83aa097bd3907b0dab016", "packages/core/src/skill-suggestions.ts": "8e6ed788637fbcaf0074ca7ee174be7eaa868847d1877ac4115693a6881f4bc9", - "packages/core/src/skill-types.ts": "90220bfad9783ae567dda80534b21e9bca649c8576cea81c784f094aa6700d6c", + "packages/core/src/skill-types.ts": "defe9f35a07524cb225d715d76c862f248f774a48e84861cf7514426070ff0c0", "packages/core/src/skill-usage-types.ts": "c3d6ba8ac55375e1d0d23ec6e44d2c1f191ed9559ea04b1cd04dbf5894409c45", "packages/core/src/state-machines.ts": "b61e94aefc4fa194522ed2a863e4eb87ca5cec1c823dc36157dea1cd3f986cb8", - "packages/core/src/storage/event-log.ts": "0dd92941a5928667265e24c7ec7c0a7e86f87009bcfb9107363cd4795f4f7474", - "packages/core/src/storage/sqlite-run-store.ts": "e6ce1a6c54a05037f4e0c430686de2885186eac39267476632134f1c3826b820", + "packages/core/src/storage/event-log.ts": "cee7877f5df9ada2b9105afc2b9b6be0b6a1c01d65fa72644fe021f4e595eab3", + "packages/core/src/storage/sqlite-run-store.ts": "59ec3cb6b657c5d0cba5dcb938340599ea8a7fa25f2b5f1e5ca7968d0f54b1f7", "packages/core/src/storage/sqlite/cancellation-transaction.ts": "7caa111ab5edfcc2be26991505a806974a528798296d2379f423377e1cfb1bbf", - "packages/core/src/storage/sqlite/cancelled-attempt-resource-veto.ts": "7ef1e2f1aef82b0009319bd1a9a68c9252a0770c1d7a1f3dadc05af1a8db1535", + "packages/core/src/storage/sqlite/cancelled-attempt-resource-veto.ts": "5e052ffee36a7c54e0af5111a055e1ca2c1f4fa35f58ced0e489dbe9467b6155", "packages/core/src/storage/sqlite/ai-council-mappers.ts": "418842bc7b9eceeb23f8c3c9f1e39b5d2cf3383a28610085bd958b3ae873c563", "packages/core/src/storage/sqlite/ai-council-store.ts": "407f8dbdbb45314ac718f2f09c3159c0ad23f43eb15da2e7a2a9bc090b328c57", "packages/core/src/storage/sqlite/approval-checkpoint-mappers.ts": "80da8bbea775355693e509f8dbd8b26b55558e56bc141bcac994207d18e99e9d", "packages/core/src/storage/sqlite/approval-checkpoint-store.ts": "bb2c675f046084469fa232eb16b5a3e35ad40431bf5f3c55d87a9a8c1cbbbab8", - "packages/core/src/storage/sqlite/approval-media-mappers.ts": "934b3ce8d435be364c9f1fbf91739539e1fffac52b5f9a9b085c17aac4a4ef31", + "packages/core/src/storage/sqlite/approval-media-mappers.ts": "b4c07ec52f9e0e02037cd0ee2d9199df9101b7cdd78462f842d3803ce909d290", "packages/core/src/storage/sqlite/approval-policy-store.ts": "53dddc225985ca345dc2399363bd991ccb82bb5c033bdbce542296e742ea6842", "packages/core/src/storage/sqlite/approval-postcommit-migration.ts": "d885798d702233839797f98eddaa4c737bf9192329e64bdf95f1f494fd274603", - "packages/core/src/storage/sqlite/approval-postcommit-store.ts": "f4d17dd01184efc1f480ba07ed04f711933a7934d2e38a655d82fae89ddfd86b", - "packages/core/src/storage/sqlite/approval-request-store.ts": "c32ecdf271e0191c8a161689d09b39254ac057368a2c1631a468b71963611339", + "packages/core/src/storage/sqlite/approval-postcommit-store.ts": "3fb78acb9f880a55afcf59509cc3e6bdf2f9492e7240caf6e0a6ab62247908b9", + "packages/core/src/storage/sqlite/approval-request-store.ts": "3774a4da70f0ed11553d0b69126a419291d9df47498e8d509d26e81694e131cb", "packages/core/src/storage/sqlite/approval-resume-basis-migration.ts": "1075a053c398e9c384507f9330f951fe0a1a8fe4469642ef8241d1a597016d59", "packages/core/src/storage/sqlite/approval-resume-store.ts": "361838014374adaaea05530025f23941c3338d2b84e5e3264987c471c711cca7", - "packages/core/src/storage/sqlite/artifact-store.ts": "ead36c16d6893d9680b0c667babc358cbea8fb27a75e0da2f6491d8d5f28b960", + "packages/core/src/storage/sqlite/artifact-store.ts": "3ebc898444dbd5c7a6100b85f199b981fd8f668b3f4e3fc20dc53ff76a61ec3e", "packages/core/src/storage/sqlite/automation-mappers.ts": "2948fe925d2d2755e8cebb106b7287ecbab180f70bc98db9c3eeae41e5f3dbd2", "packages/core/src/storage/sqlite/automation-store.ts": "b6b696cd2af8306076d7704bba5b9aafd1d815977ba6f7608040e05254e8beff", "packages/core/src/storage/sqlite/availability-incident-provenance-migration.ts": "67690913c37d8c200f2356b5e8ac06b88e53382f510112996aa30927b8e99ea9", @@ -329,10 +329,10 @@ "packages/core/src/storage/sqlite/delegation-migration.ts": "b3b5b8ab9ba33a03e1e2ffa7d8b6d0725f9ca05971d40f485581874626e93170", "packages/core/src/storage/sqlite/delegation-store.ts": "b33ec94ccdc6732101de0a830642e9344a9fd387f21d586646380dbf1aeaf77e", "packages/core/src/storage/sqlite/channel-evidence-publication-migration.ts": "8ad1962ff4733f1d024fd8782b69cfa2bd03dcebbba7a7b3c5e26c9d2667c0ad", - "packages/core/src/storage/sqlite/channel-intent-decision-store.ts": "766831e8babb5ef8e460ea05469369e619b2545f812614f65dfad47984ae7224", + "packages/core/src/storage/sqlite/channel-intent-decision-store.ts": "319e01adbc0b0df13a72a2e12a0227ee402367a995b7ee369dc213d1c32582d1", "packages/core/src/storage/sqlite/channel-intent-execution-plan-migration.ts": "ce3716e24618f3223eb1968d91e9462f7f2da42b4a4bf913f68e6a40dfbd38bb", "packages/core/src/storage/sqlite/channel-intent-v2-migration.ts": "d949ee10153a37b54f2ce2c108555873c75cb624044b69eccab686d1b2b873a0", - "packages/core/src/storage/sqlite/channel-message-parsers.ts": "709d6b8373fe4fece0a5273171153a80483f6f41bb55eca00c9bcdd50bf05907", + "packages/core/src/storage/sqlite/channel-message-parsers.ts": "f160e1e6f6495ad4ed2d1f49f89226d944c0903b77d0ee1f7058c3acfe2debe1", "packages/core/src/storage/sqlite/channel-participant-authority-migration.ts": "9c1f33f1a97f61f70267631826eb0507e817488571ea06bd5d32038d0a550adc", "packages/core/src/storage/sqlite/channel-participant-manifest-migration.ts": "1cf17bf0c87a953de843fb6b514ab60751c61554cbcc1e3b161cb4bf6f690e7e", "packages/core/src/storage/sqlite/channel-playbook-mappers.ts": "98202ef56f0be7913eed8c359b0214e36906297a3766814c1dd40f6a6e7c3fe0", @@ -352,9 +352,9 @@ "packages/core/src/storage/sqlite/channel-team-pattern-store.ts": "36f3bf6d365462161664c4ac9b9fbe91c4af9efc595cda1b6ad8b44f9fc6409b", "packages/core/src/storage/sqlite/channel-team-snapshot-mappers.ts": "38f8a0916f0202d40a723f6ee61f21ad3a0da86c22935014bf59067e23455b65", "packages/core/src/storage/sqlite/channel-team-snapshot-store.ts": "bfd0f4855fda90f8cb3a41f0dbb01211615342d305e8f694b11535c2342b93ff", - "packages/core/src/storage/sqlite/channel-workflow-mappers.ts": "26fbb8832b54192477b7148d2aca2ac628ac684a5f7be824c801026d52840e7c", - "packages/core/src/storage/sqlite/channel-workflow-start-store.ts": "5d0d16afd9c52f361438f1e01c15f1b059077a34f137c6daae6e3a508dcb6413", - "packages/core/src/storage/sqlite/channel-workflow-store.ts": "94553cd6b62b55aa57258aecb460de61a70ce3def336b81d706585bcb37f17af", + "packages/core/src/storage/sqlite/channel-workflow-mappers.ts": "82002ff614c39e395c6227215df3ceb873614d6a9f993b4bcc57f745324f7150", + "packages/core/src/storage/sqlite/channel-workflow-start-store.ts": "89815f73bf49538a30eeedada212816e5206de8fe860a1fde68e0fcee206327a", + "packages/core/src/storage/sqlite/channel-workflow-store.ts": "532182b5eb33eb9edee3554d52594d070975b800f3bce283706cb407af7c9049", "packages/core/src/storage/sqlite/chrome-browser-credential-store.ts": "7025e7831f252f17ac389f436ae84d87d577ef6315bd1638c3c0a71f3c6578ef", "packages/core/src/storage/sqlite/continuity-recovery.ts": "a42de48168dd7504d52cb59b49fe1e7d60dd9474bedf1a295b4e89d7e0ed44f6", "packages/core/src/storage/sqlite/continuity-v2-context-import.ts": "579f3ac177a3a1b7e857fba00d8699f615bdbda9c4bd86d3790136fc65ab3e06", @@ -369,14 +369,14 @@ "packages/core/src/storage/sqlite/conversation-accepted-input-migration.ts": "fa8bbc10ceaf28c8514b721cf152401cce8a3da6743e21b54f40a4a33810b6b8", "packages/core/src/storage/sqlite/conversation-accepted-input-store.ts": "883d12ddeb25ebff68356315dbd35679f99af1f2f2f0b03986e7e5e50f55b0ec", "packages/core/src/storage/sqlite/conversation-asset-store.ts": "0930cfbf17ed3f5fdabc1502d9c1eec82bb8fd65ffc037b492b71d8054586819", - "packages/core/src/storage/sqlite/conversation-context-store.ts": "a63db29fe9fc3277034e5151816f3d0793943ac2611d863c1507239fd61f7971", + "packages/core/src/storage/sqlite/conversation-context-store.ts": "f63c6fcd2545c4653623f6a6a2c3a82cfb0229f5194a08d0cd8c94453d4bfb2d", "packages/core/src/storage/sqlite/conversation-context-summary-store.ts": "9d2e4ee754e110d775169997a328d6d040a9d30438629d614cacc9c03971fa9d", "packages/core/src/storage/sqlite/conversation-item-store.ts": "58b0da3ea9df759a34b3f469861e6f9846e1aea86722f7698c42d4c11e51aa8e", "packages/core/src/storage/sqlite/conversation-media-execution-admission.ts": "cd57822aeddb7de94b813061c987138fa78fd02793b913cdbb2a82692a26e579", "packages/core/src/storage/sqlite/conversation-prepared-input-store.ts": "7c097674b590efa50e98f9cd9ce8cf5f9f15be1764f848562c1c845c462a5774", "packages/core/src/storage/sqlite/conversation-revision-store.ts": "3ec5dac483451e49adea88ed032f8f96f685a60356112f24afa03775cd64aa21", - "packages/core/src/storage/sqlite/conversation-thread-topology-store.ts": "2d1ac3c2976d3cc06c5a83fb05d7222406c72cebfa13e7afb903b8b64e546e6c", - "packages/core/src/storage/sqlite/conversation-turn-ingest-store.ts": "481f6f5e06262ea0f42db3bfa48be67a064c39a7f06ef8ac672707824fc04573", + "packages/core/src/storage/sqlite/conversation-thread-topology-store.ts": "d1614a2c5798c7754defcdd3fd3675ededa7d3bb2c36bbd9ba70a7728b1172a1", + "packages/core/src/storage/sqlite/conversation-turn-ingest-store.ts": "ba9da834e415ee603cb9baee82c74a313a3fd0e8f22e3a432e20d0968ce74ccf", "packages/core/src/storage/sqlite/cua-driver-activation-store.ts": "669e7d15068e5ffbf8cc676af9c166fd93fffe9593090dafb828cdef7e2e9391", "packages/core/src/storage/sqlite/external-work-action-store.ts": "722fda3264a817fc132528c7ce83bdb3dc4cbde3057f484d720008036134a13b", "packages/core/src/storage/sqlite/external-work-ci-triage-store.ts": "c99c898cb1b33561174488850d3acfda31ab8b6257cd08708c631fcc39324192", @@ -385,21 +385,21 @@ "packages/core/src/storage/sqlite/external-work-store.ts": "07c3d04490e159a80bb537cddb50ae6fc6b77b2a3261fd28a09a6bb65b26aa23", "packages/core/src/storage/sqlite/external-work-workflow-coordinator.ts": "e4c6742201257c9f704489649af727cf63cba77790775ca1276284d519497da6", "packages/core/src/storage/sqlite/governance-notification-store.ts": "e812aef0ce5790ad033e49dff755512725875a8fba56cdefce51cd341973b8fd", - "packages/core/src/storage/sqlite/harness-commit-coordinator.ts": "27ca70d369004a92c50c61a053ae9579ff8200c29bbbe3571122c851a379e8c4", - "packages/core/src/storage/sqlite/harness-diagnostics-mappers.ts": "c94284f1d77b0ca1fba3ce3f918bb788812f5bb96b5d3de4826732cc706c9f1b", - "packages/core/src/storage/sqlite/harness-diagnostics-store.ts": "a8879b696084de93900a368fa7a656564323ab8955e97a8c6270abba2d6d9855", - "packages/core/src/storage/sqlite/harness-diagnostics-validation.ts": "2067291d28f81f7da31c42e98b0ee8c5c8882a7d3a50466437b75b010abe9933", + "packages/core/src/storage/sqlite/harness-commit-coordinator.ts": "e9b909d4ba4f896f7ca9959c5ba79147c19e18e6b6a5208f8f2e56adb1668c7c", + "packages/core/src/storage/sqlite/harness-diagnostics-mappers.ts": "4e2a5e6a56ffb9223e9d96b983e83fac00eb9bac00fd9af9cbadecc890548235", + "packages/core/src/storage/sqlite/harness-diagnostics-store.ts": "29951270aa0fe8b842207934cde9e09f1693f3f804e746962d354d9bc7b52db9", + "packages/core/src/storage/sqlite/harness-diagnostics-validation.ts": "f9f931db6a044aaa93f021f16cdae551ed7df3de7a5179e056bff5c2810beb3b", "packages/core/src/storage/sqlite/harness-tool-execution-mappers.ts": "c1da1e51f9c630f20b4b97aca70c36cae88c0288b5c0ae55eca65a3b280bd0dc", "packages/core/src/storage/sqlite/harness-tool-execution-migration.ts": "5f81db3614ed01fdfd00dd84ea118ea0f7ac59622866e7c038d1a4658248b6fe", - "packages/core/src/storage/sqlite/harness-tool-execution-store.ts": "cca314669750b3e4e440a8ffded9e9ec5a1002d026bd1e09a0024ea049271be8", + "packages/core/src/storage/sqlite/harness-tool-execution-store.ts": "c3841085044f3724ad89cf52479fa7ed6f90d8b10682990b51faaca4062b4cd4", "packages/core/src/storage/sqlite/helpers.ts": "1b80491e0497e48570bd6ccc680d9f6fb1de78263c579780126cd54373cdc329", "packages/core/src/storage/sqlite/host-attempt-completion-migration.ts": "cb4d9180c133985bf8538958c4c39491580ccc0708a518e6dc3c0bc547653e5d", - "packages/core/src/storage/sqlite/host-attempt-completion-store.ts": "fb81ed4cb13c90c74b376bc04877571f402b1d6d2f22d9f30cb8dd3a824efaf4", + "packages/core/src/storage/sqlite/host-attempt-completion-store.ts": "38c0384b3373121bae6c0789898873d2619d5936742935bcd51e94faf22d8773", "packages/core/src/storage/sqlite/host-attempt-effect-coverage-migration.ts": "8088d99973ac13fb21109756d832d955d93f32237b4e9eb85cae5056a48eddee", - "packages/core/src/storage/sqlite/host-attempt-tracked-coverage.ts": "4fca5d925710943300979ef31b9cdba3dce803b5e7b4cb4d7e4f616b801ccf71", + "packages/core/src/storage/sqlite/host-attempt-tracked-coverage.ts": "29f7cb6ffad20884314e99a6d334d52ad9c4bc3f9fa6e89474dfe94357d8996b", "packages/core/src/storage/sqlite/host-command-journal-migration.ts": "78fe65b3e71cf330216e9fdd006017b3a84cddcdf2be749a77b5490f61b88be5", "packages/core/src/storage/sqlite/host-command-journal-store.ts": "575997e8ae872f849739537a1c7316e6659dbb0234a0db009d550dbe327ae3ca", - "packages/core/src/storage/sqlite/host-compaction-resource-boundary.ts": "c808a34138a61a514e8b1008a61cec450a296d74451dd97b8f6ca9f68bbeec52", + "packages/core/src/storage/sqlite/host-compaction-resource-boundary.ts": "3dda457a3f143f564c91366baba88858a8f146dfa18a773bad7be933e942e697", "packages/core/src/storage/sqlite/host-launch-preflight.ts": "1f3edc912ee15c89bd344ffb8e163ccffe130ecc385d2baae0aa899163b485dc", "packages/core/src/storage/sqlite/host-media-operation-facts.ts": "3e5afd1c557c0a8a5187a16b87d7ac742049e7f24072be3d2801ad6f5b56f22b", "packages/core/src/storage/sqlite/host-media-operation-migration.ts": "2f6891413c329b4494fc42fa3551d8886a6fdf76c0b365598794fd19a20fdcc7", @@ -437,7 +437,7 @@ "packages/core/src/storage/sqlite/host-office-local-journal-evidence.ts": "2c62d972b58b7ca87eee8257e38ae48d9a4524def73f05667fa882c3e75528fe", "packages/core/src/storage/sqlite/host-office-local-journal-gate.ts": "8fcbb47ed77eca3dec43364aa48795d1c07a4710ba1c4b3df35f999e9e92cfb6", "packages/core/src/storage/sqlite/host-office-local-journal-migration.ts": "adb5776c01316f98e8f1e9ca472d54ebb9c59063575c6a2bc748f9fd3c0bccbc", - "packages/core/src/storage/sqlite/host-office-local-journal-settlement.ts": "8a697d1a705af6f24ebde3bb672750eb93f48fb93fda240725405ef0ac557251", + "packages/core/src/storage/sqlite/host-office-local-journal-settlement.ts": "5bf8529bce52d9c2c82a3708ebf30ef41f7dbbf530aff55393c6e12b286939f8", "packages/core/src/storage/sqlite/host-office-local-migration.ts": "cfed79ce0dc5a008ea46e5337177326af9170c668a8583bf778fa0be84486313", "packages/core/src/storage/sqlite/host-office-local-native-v2-publication-schema.ts": "80aab75e0120b473fcc21d7d161dc92de216b62fe4e56fd1e4a475fd90d67935", "packages/core/src/storage/sqlite/host-office-local-native-v2-schema.ts": "2121d43f421a66dee1b92b4c24c2fa504e3eef67a684cff4e789697ac61851a9", @@ -452,14 +452,14 @@ "packages/core/src/storage/sqlite/host-office-local-terminal-migration.ts": "8087206e0292839e24fbedd479834badb1bb8b6cca34cbe67232edfebba64339", "packages/core/src/storage/sqlite/host-office-local-terminal-payload.ts": "39e93ac1f2a8d4bac54be850fd2d782571190a5caee1ba34c584791b83217841", "packages/core/src/storage/sqlite/host-office-local-terminal-records.ts": "48b64e1a65c0d12ef7a891eb8e021442a1849ac92c1a170f427a3900b410943d", - "packages/core/src/storage/sqlite/host-operation-journal-evidence.ts": "2a05131acabdf583031156adf21df1ec009965d9489dac162cabbaf30cbbefff", + "packages/core/src/storage/sqlite/host-operation-journal-evidence.ts": "445e88e0f54af16308293ede6e8a29ac4d72bd0b3cffe9899cab10e06e25db01", "packages/core/src/storage/sqlite/host-operation-journal-migration.ts": "d68d85913cee8acd450d827c0ab93e19d0dd5ce82c5c9208d01b78c0433e34e3", - "packages/core/src/storage/sqlite/host-operation-journal-store.ts": "e337e62fef0089fe9257090a24ed525cd22cc23af7694608b8efdcf5bc7b2e82", - "packages/core/src/storage/sqlite/intervention-mappers.ts": "abd99103d49492bd4b57e43beca2bf2184530c862f9fc8b7b0dfd6a0e47cb0ef", + "packages/core/src/storage/sqlite/host-operation-journal-store.ts": "97f7c3eec8569a8e4aaea910399bd011c3375b36740a1e3a6475c14be771ce25", + "packages/core/src/storage/sqlite/intervention-mappers.ts": "3d3446a374b61db484b816986fda4865b687c0f8a4916b56991a1a594c541ae0", "packages/core/src/storage/sqlite/ling-thread-cleanup-store.ts": "fdd9df90fa8c7d6898faf78a2395e0886dead117750398a5ec279c00cf621da6", "packages/core/src/storage/sqlite/ling-thread-metadata-coordinator.ts": "7b3d44d67bba9b0ed3c801fe45511a8a8de2631ec97a2b71cae9e215aed3a796", "packages/core/src/storage/sqlite/ling-thread-search-store.ts": "380731523ad2732f7a28477f146ea9f5114df31891233dc302899f6f8f937ea3", - "packages/core/src/storage/sqlite/ling-thread-store.ts": "86bc2c3bebffa48aa215d93cfcf3226fe3706d78cf8c9fd4c1aa16ed2b57ee6c", + "packages/core/src/storage/sqlite/ling-thread-store.ts": "2a01142c4817331f75a98ecef98a2123d80d260ad7a1ec1103501849715c82f9", "packages/core/src/storage/sqlite/ling-turn-cursor-indexes.ts": "63d9e8777319eb935555cb931a171d1a344e9fa47abec0140046bd13bd43826b", "packages/core/src/storage/sqlite/ling-turn-store.ts": "bb4e2f5f197fa4112d099cad5cdd6359d69d170f703a1b07aebe0c7318b67aa4", "packages/core/src/storage/sqlite/managed-plugin-mappers.ts": "fc987a96440c0b1456d22c7f8409819290e31158f2a3dcce2a584b3c21250ccd", @@ -476,18 +476,18 @@ "packages/core/src/storage/sqlite/memory-consolidation-store.ts": "e0e62f1bf63a1105031803accaf345ab85f6b010ea900f9ebfbb96dfb26194b1", "packages/core/src/storage/sqlite/memory-mappers.ts": "4cf94d30afcea08a4cbd43eaa85790a655f8a8a8d57fbf14235980b934d8c0df", "packages/core/src/storage/sqlite/memory-object-store.ts": "9bab45b35409d72f78d5144ec996351e784ff04235ab59bfdcc059cc370a7992", - "packages/core/src/storage/sqlite/memory-retrieval-store.ts": "fcd2016509bb3bcb0710aaa303f0b072723bd1e66696a137440c6c6fae5e217d", + "packages/core/src/storage/sqlite/memory-retrieval-store.ts": "01f0eb9b5e08b560cbcee46249987b440fabed821de32f8ccdb8130cffa039cb", "packages/core/src/storage/sqlite/memory-review-store.ts": "82d3170cf9404e84f271aa33d07e0bafe5725fc0daef342bdd42a8c94fd4322e", "packages/core/src/storage/sqlite/proposed-plan-action-store.ts": "aaf1884b675ee93302736bfff2112b94e599c3f95643855259eb5661e3a3a831", "packages/core/src/storage/sqlite/proposed-plan-revision-migration.ts": "49fb06df913507fdd941dbc79809ac53360084fc23607722ccc7450695fc38b1", "packages/core/src/storage/sqlite/migration-coordinator.ts": "9429cb0caec2d85e5db02903c4dab579fa747294fad56584e81f860a5ef7258a", - "packages/core/src/storage/sqlite/migrations.ts": "48666136657e1649f79943c1ff3f2bc289bef9f1e2e96abf9088989addd0e4c6", + "packages/core/src/storage/sqlite/migrations.ts": "cc995fe5b4a7fe47538e97a2402faf127bf52af83991dfa7c4591f74650ba501", "packages/core/src/storage/sqlite/normal-conversation-fork-store.ts": "56f27ace1ea452ac4c1e82a04117552a09a938db3c3306b30edd6ecf76b0d5a5", "packages/core/src/storage/sqlite/normal-run-narration-publication.ts": "b9c6d9a9496ca00bbb1989d884141d27ecaa1fc98d9dd748a8e46f1324b8355d", - "packages/core/src/storage/sqlite/normal-terminal-report-publication.ts": "e689c6c8a4d52a4817d3989b836eee25e8ba92a8cb5fa96c28adcc1488f2cc3c", + "packages/core/src/storage/sqlite/normal-terminal-report-publication.ts": "291d2075cebec8b7d19f6138f0ba4c84ef2464546414eb2a209efe539cceb129", "packages/core/src/storage/sqlite/post-compaction-undo-migration.ts": "60daf089c1c583f4d60c954e6ceedc0e067b62dbc181dcd09a795a136ae63e3a", "packages/core/src/storage/sqlite/post-compaction-undo-store.ts": "6bde08458f8c974647a4dee0d3c29f3e40d6b80cfce3067439d73e8899404c59", - "packages/core/src/storage/sqlite/procedure-observation-store.ts": "fc54c0d3c1de960ec39daf69bc09173937d262a4f23760c7893444180df7c35c", + "packages/core/src/storage/sqlite/procedure-observation-store.ts": "16a2a3b9b5e804a2c46e574064a2ab57b7d75c899b1add71e7aa52836c10ae3d", "packages/core/src/storage/sqlite/provider-mappers.ts": "acc6f363034b5a847215c46b5cdf332f45bf23c94ccb860617782e15ceb5e189", "packages/core/src/storage/sqlite/provider-store.ts": "d91fbef4db092ae471811ef97cfbe41c21dbe26823f3ed68e4cf08dca47ba14a", "packages/core/src/storage/sqlite/provider-check-migration.ts": "7417b622ed35f7d9204338a1116b6056598fe60b947e8ee68695e447a3e2e086", @@ -495,23 +495,23 @@ "packages/core/src/storage/sqlite/read-snapshot.ts": "7f4eab00e3c982b19d70ba002e42edba30361cd4659f424f89172cae949b6ef4", "packages/core/src/storage/sqlite/recovery-discovery-validation.ts": "89f25949b620e0bb4f1e9fa6ab7e9946d82bacb4db800b3e08431fd93800cf0e", "packages/core/src/storage/sqlite/remote-access-store.ts": "487f2981689950c03ff6c032fca1e4fb714eb4bf8b7f9db3f842925e21ad2303", - "packages/core/src/storage/sqlite/resource-lease-store.ts": "151e71166c0bcac949a8994111ce007bb167c8348081aacae30e94eb733623cb", + "packages/core/src/storage/sqlite/resource-lease-store.ts": "38cb7834170e6a7d41afc95c9e14139488ecd82e96359eae3b8a4c8a99f4f7bf", "packages/core/src/storage/sqlite/run-compaction-checkpoint-migration.ts": "b1ad5de2d03cd3b5d07eb0b15b9123b6f4f3a7de06ceb483f14f15df87224c39", - "packages/core/src/storage/sqlite/run-compaction-checkpoint-store.ts": "8c71123d13ace7a8ca2fcce9934586247a8329935b90a65cc492e110302785a7", - "packages/core/src/storage/sqlite/run-progress-checkpoint-effects.ts": "a83db5d31bec646580aaca06b6d056c50ea286946c4d4d85322bbe2a170c45b5", + "packages/core/src/storage/sqlite/run-compaction-checkpoint-store.ts": "dbf52272bb14a131f0a8364364d0fd1687a33e9ee45583057514d4df62fc817b", + "packages/core/src/storage/sqlite/run-progress-checkpoint-effects.ts": "25356d7f3ddf997f5cd2f9ce8ab324d17114d747580e2afb5169a3472fd715f3", "packages/core/src/storage/sqlite/run-progress-checkpoint-migration.ts": "938b312f4a3fc36255541e59c69979fda2e86e985d9a9a49f17bd6a77cb6e657", - "packages/core/src/storage/sqlite/run-progress-checkpoint-store.ts": "ccc4ab1475bf75b1661a0a1959abc67a96b838239404e291823c7d7c8949e8b2", + "packages/core/src/storage/sqlite/run-progress-checkpoint-store.ts": "90774de19cc0ed89b1cfa6e9b9c1931c35ce4a106cc63bed0ae7eb358d354094", "packages/core/src/storage/sqlite/run-context-view-lineage-migration.ts": "2e13e3320ff3a8f3111669fdac140f23c1dab9ef2c1990ee33c0415ba5a7947b", "packages/core/src/storage/sqlite/run-evaluation-mappers.ts": "2dd6f3cd572397d6f1468d29deb45aad1bffe5db76ca2b13b7346c90f6f423ca", "packages/core/src/storage/sqlite/run-evaluation-migration.ts": "8973191fca247e8d5c35ccdea3b2d1c2e8b16b60be190fa18d718b0d78d8f0aa", - "packages/core/src/storage/sqlite/run-evaluation-store.ts": "5e4b28cdd9b04a68b9ea51454398739b2e0086424b68353bffc2bdeeff94a946", + "packages/core/src/storage/sqlite/run-evaluation-store.ts": "e1a4328f7cee424143d4eeca188a961d0880f668ff15be3a2b84cbf1447562bd", "packages/core/src/storage/sqlite/run-evaluation-task-mappers.ts": "d46d5f5331e0b019fca9babeeaa375bdb1e58283db7826f12b0499c57b325f0b", "packages/core/src/storage/sqlite/run-evaluation-task-migration.ts": "fb4a66e554f4bcae6607ff4df44f8ece7003ff48de9dff3ecc20dc222953d9ef", - "packages/core/src/storage/sqlite/run-evaluation-task-store.ts": "e09a7a2386eb2c768bb526c709b530324809d6ba8e001b342edd542b16448268", + "packages/core/src/storage/sqlite/run-evaluation-task-store.ts": "19c19022d8a4ee74f195a8a6523e1d85f26e2a0828ab35daf45eecd177c26393", "packages/core/src/storage/sqlite/run-evaluation-task-validation.ts": "76c0e3c37625a7558c07facca4d2377143e2a58925659fc7554c1707fdf50fc9", "packages/core/src/storage/sqlite/run-evaluation-qa-mappers.ts": "8e833b26093fb5901f253917eb6dd246f44467e83ada61a1dd6e98d8fc861444", "packages/core/src/storage/sqlite/run-evaluation-qa-migration.ts": "bbc4ceda442db937cf93fefe9f3ff8c67be66f86bfb6cfce82eec18d4802af70", - "packages/core/src/storage/sqlite/run-evaluation-qa-store.ts": "f0465666950b31e906bdbb0e37ad54c7444a240ec76c8c8ccdc0361782186249", + "packages/core/src/storage/sqlite/run-evaluation-qa-store.ts": "d1ab8a6cc530f4bed7a2cc064515e1ab3f241d0687fd532a5bd3e57643657985", "packages/core/src/storage/sqlite/run-evaluation-qa-validation.ts": "61e40f7bf7d0db7da8ef173f246a8562edec5693c4cc97eecf16bb58b2b68cc8", "packages/core/src/storage/sqlite/run-evaluation-human-mappers.ts": "ac1d84c4fe582f3c9169bbe5781e86a019e08529720a0ab1e121613cd4fc511e", "packages/core/src/storage/sqlite/run-evaluation-human-migration.ts": "527c4595bbd8c1cf4c5716726dcb6d4b15aa1734e33e5c0b411b082871cb34f9", @@ -519,23 +519,23 @@ "packages/core/src/storage/sqlite/run-evaluation-human-read-validation.ts": "4da23a9b7c7f5dd8e7951dc564fe6498afffd65e46e378af41aeac403845916f", "packages/core/src/storage/sqlite/run-evaluation-human-store.ts": "877831abd56c834944790ef102f91294a92c26fc95aee8014762ae4bf6a9a073", "packages/core/src/storage/sqlite/run-evaluation-human-validation.ts": "afa3bd0ff9854e553c792b81a3816be1c43617e7e70e6cc3cfe9b85dd7a38db7", - "packages/core/src/storage/sqlite/run-event-store.ts": "e3350883a442dae69fbf7fcca6a63dc1dbe83c1a91bc114ba4eea2358a53c244", + "packages/core/src/storage/sqlite/run-event-store.ts": "02bcd919f8056697b3127fb12b88a0703a2ce8b7f5f86e6c628578e080c068fa", "packages/core/src/storage/sqlite/run-execution-admission.ts": "13ba57ba7988fcb7f936b4cfc5b2f106a03d906b40e895e6961dfef085780427", "packages/core/src/storage/sqlite/run-intervention-store.ts": "c36d178ec95a8f769a8d16070ae91640de5cc3f84ddacc73cc26c3568a1d858e", "packages/core/src/storage/sqlite/run-mappers.ts": "d83a6d7bd64b044ef1e9891ec3c131a583bd44fd28f0527adfa2a3c469ebec39", - "packages/core/src/storage/sqlite/run-session-store.ts": "e1b9b7c8b43a2c684aea450f8553f5510360925d4879cf3868550ee1bb36a13c", + "packages/core/src/storage/sqlite/run-session-store.ts": "55d6dd1e9d00ec0d4eea9fd9c4662075af8cc798870b53b34d788807c4ba5da1", "packages/core/src/storage/sqlite/runner-continuation-mappers.ts": "6f65a9dfcd8dd8979eaae7a271c5e6eb8dd5fe711d937645f83c3f3ccfdc8077", "packages/core/src/storage/sqlite/runner-continuation-migration.ts": "c36cedd0e82261d4fdc70508406a6d4ed884e0e3fcf3e80df354b3d03f6327ed", - "packages/core/src/storage/sqlite/runner-continuation-store.ts": "4fa4a35ac1d46a81dd59f288f4d421854581a9bf5f414c396a4b597d77c09c4e", + "packages/core/src/storage/sqlite/runner-continuation-store.ts": "4a692c77c9eddb7add7fc832d941cb6681d3971a355019166e8228f30b561a5c", "packages/core/src/storage/sqlite/runtime-attempt-fence-migration.ts": "d290595352dc63b00905ef7cf9a63fc4d627b39bfd30eed941b70c31201f0bc7", - "packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts": "dca4c3065c94d8411e319cb976090c914135cbf25da83ec9c56ea041e08087ed", - "packages/core/src/storage/sqlite/runtime-semaphore-store.ts": "55a21e26e0e705891a4084233144c424ea419dec13959d5d7421916e71892536", + "packages/core/src/storage/sqlite/runtime-recovery-coordinator.ts": "b74d6d3f9972e267a587e21dc2f2d1e557009b64bc4ebe6d9acf92111f022478", + "packages/core/src/storage/sqlite/runtime-semaphore-store.ts": "7599e42d2049b25d335ee14b7ef0a7123e8f752f08a0820aef683392ce377794", "packages/core/src/storage/sqlite/runtime-work-mappers.ts": "d757a1798d14908886aa6fdd9b97bd581779c7261442b9425d912e67c30a4015", - "packages/core/src/storage/sqlite/runtime-work-unit-store.ts": "a7efcfe74f7d2298b7641769171a6f70727ab68ae756d9b3fb2a2721b167d29c", + "packages/core/src/storage/sqlite/runtime-work-unit-store.ts": "debb7d8443fdc8836a15ea7ad73b6699e69c8fcc09cf2fb3be464c831230b2e0", "packages/core/src/storage/sqlite/select-sql.ts": "657297f64af7354ed9d4147257fb2119a6a071466dcfb786b2ed34c9daac883a", "packages/core/src/storage/sqlite/skill-candidate-store.ts": "5d7075ac82233fa96a0429909216c73eaff023b603e675cd48f460f2f115d13c", "packages/core/src/storage/sqlite/skill-capability-migration.ts": "2daf3d0a7daed4fdbe5a55e0b96d60f9c08f28743c3ea72c6919db76b181c937", - "packages/core/src/storage/sqlite/skill-registry-store.ts": "8cb3813e07fda19ca687d5680675158ee7a664dc2f8d0854c8468afd615ea0a9", + "packages/core/src/storage/sqlite/skill-registry-store.ts": "cdda7e7634c1cef46dfb24377bfd3428586528871c25f9b98233b45177a3d361", "packages/core/src/storage/sqlite/skill-source-publication.ts": "b4f34ba9e9f3ab3285d4ff2229aaecf0c16dd3fb32545bd0fe307116986dc6f7", "packages/core/src/storage/sqlite/skill-source-rollback.ts": "b7b7671057d6b25a3d47657276eb2bf8f2ef2c7af488abab733356f45de35f01", "packages/core/src/storage/sqlite/skill-regression-mappers.ts": "b57c83ee374705691ebcedd8d516036879d30a29ec49fcfc7202934c8b72637c", @@ -545,9 +545,9 @@ "packages/core/src/storage/sqlite/task-skill-mappers.ts": "1bdf080cd8833eb16b4f4c7c466d22d7eb64d5ed7863a842d2ad09ee33757c64", "packages/core/src/storage/sqlite/task-store.ts": "d9d483355219d72a12b33c19bffa151d4875dd39b1faa20b7c336dd1912b0013", "packages/core/src/storage/sqlite/teammate-association-store.ts": "bf5988a7a58cc93c553d58f40fd7e89e556cc975ce116ad35b91db44829779a6", - "packages/core/src/storage/sqlite/teammate-bootstrap-coordinator.ts": "409e287f7e7ecd7246cfe28e833e976c4e6c3dfcd7f381ff8d66bc3ece0d342b", + "packages/core/src/storage/sqlite/teammate-bootstrap-coordinator.ts": "84333e68e849e999468b385468c5305e1da1a37d109e790f651a0b28100e17f6", "packages/core/src/storage/sqlite/teammate-cleanup-store.ts": "446bbdd900d414e1628f0338c2c022d4475099374764bebd0d61d94395143fea", - "packages/core/src/storage/sqlite/teammate-core-store.ts": "1109c45fee2246d463e6412fa8f4654ef7f92206326087ee72e2bb30580da616", + "packages/core/src/storage/sqlite/teammate-core-store.ts": "e79346ff651db7d2f16f5d60b9d98d8eccd97be52e98b96447cbfe4b80f2cd63", "packages/core/src/storage/sqlite/teammate-direct-input-store.ts": "9b64d7bb244994c75bbe2cf05ccf9550db467f4de4282314308c08dafa018885", "packages/core/src/storage/sqlite/teammate-direct-intervention-fence-migration.ts": "9ac3d0b665a156fb61dc28a894ed07d2d30b84e021ad6c9046f5809153af74ef", "packages/core/src/storage/sqlite/teammate-direct-mappers.ts": "01442cbbb228404d3dbdd67734cb8d9ff1d388c3cc32c782b9ca8f42dbbde8a4", @@ -559,27 +559,27 @@ "packages/core/src/storage/sqlite/teammate-direct-submission-store.ts": "b1128f335270cd98f56bc9df93c0913ecd22fc30e6001696ffe49658d6814429", "packages/core/src/storage/sqlite/teammate-mappers.ts": "e3d9bd708763c8eef9bd83eb128a007f7a216d91d14b4521e2ab6ee32b7e86b9", "packages/core/src/storage/sqlite/terminal-report-mappers.ts": "6b64148a0ec95c63cf1d38d26bffad671c15ff6c4ca00d8e5043218d936d3888", - "packages/core/src/storage/sqlite/terminal-report-publication-store.ts": "0b450448f2edd5c0691b5de39a549958e9a127eae16d34c59571d55b6460dc7b", - "packages/core/src/storage/sqlite/terminal-report-store.ts": "7b2a4e63b2ad5aa15d1eaadcd82dc1d96f2195ff86a8fdd0f4097815bbff5018", + "packages/core/src/storage/sqlite/terminal-report-publication-store.ts": "92b881eac51b596a5567ddf6039629edf570deac76573f029295eb1c47be9469", + "packages/core/src/storage/sqlite/terminal-report-store.ts": "a74360749eee705ece443d9bc8999463344f7af9ea6b40797ce49bedda504870", "packages/core/src/storage/sqlite/user-input-continuation-envelope.ts": "9e4868233648d86ccdc515dec4b5c0ad44223ace43e183acd551687455f0fd4e", "packages/core/src/storage/sqlite/user-input-continuation-records.ts": "99f6a855cf59e6ba3aa7d4eca960280e9b0d633cd2f2adda88d5dff30d602bd6", "packages/core/src/storage/sqlite/user-input-continuation-store.ts": "717d27d53288a7eb21ab70f76c7518ec5a030c43820eebfb704f9e0d0a115f13", "packages/core/src/storage/sqlite/user-input-delivery-cancellation.ts": "a7c7ab434e9dd2e6620f2e6a69111e4c0ad6a1b55b487ff69e7e11f489438eda", "packages/core/src/storage/sqlite/user-input-delivery-migration.ts": "48290bcbfd7706e6a44f78246fceef1f546736e2b3ce1b00894ba17755157fe6", - "packages/core/src/storage/sqlite/user-input-delivery-records.ts": "04fa2ba843fb5fee03f2d4506d89320a9af2749d0bd13a13fffef6428b4b4a51", - "packages/core/src/storage/sqlite/user-input-delivery-store.ts": "45c74322fdaa778ec570420779b27d84408f1dc5bdfa593bdd608bfc89eb7f8d", - "packages/core/src/storage/sqlite/user-input-durable-quiescence.ts": "280b893868d3325d434ff7a680ca2a49d52c5203777dd4ba294d547c2ba90275", + "packages/core/src/storage/sqlite/user-input-delivery-records.ts": "05d4e3578ecab61eb91321e4ed26c7269240b242439b1316813b86a387c44b7e", + "packages/core/src/storage/sqlite/user-input-delivery-store.ts": "7bf46695f3663f0278380a6148d4dcdb68fc7199da8355d1d71b906c891ba70b", + "packages/core/src/storage/sqlite/user-input-durable-quiescence.ts": "c289ad67239821383ecfe73e1a015ed4fc5f44a7e9371f06a3fde01894d9a8f4", "packages/core/src/storage/sqlite/user-input-event-position.ts": "f7a859cb28d1e9c481d08234de0f36f0832a0131b52d97a7415c2e07dd622770", - "packages/core/src/storage/sqlite/user-input-host-continuation-store.ts": "97f8743d1ea6a395d2c94eaa76b240269f5fea692c3b5e72b08417eb514d039d", - "packages/core/src/storage/sqlite/user-input-media-settlements.ts": "f6bb5da4221ae5580aabfd5f7f1e8bd1fbc68ac5353de7790a15f09a5a8b6745", + "packages/core/src/storage/sqlite/user-input-host-continuation-store.ts": "0cfa55ed0794791a0ff2fc88712e55698609b844df5aab016bf29259028752a0", + "packages/core/src/storage/sqlite/user-input-media-settlements.ts": "d7242208fbd4968825aee96c5ad1ced480d197662dc7e33d3786e4357bd2e296", "packages/core/src/storage/sqlite/user-input-migration.ts": "ce7477d7d01409114b75dea2c31b031bcb00bc56473b8ccdcf86c112340ac1b9", - "packages/core/src/storage/sqlite/user-input-preparation-store.ts": "72a442b7c5c29fd1ca02011f08e9275d0d37f4f2a06f5acaa3ec1d6d5266571d", + "packages/core/src/storage/sqlite/user-input-preparation-store.ts": "b0eca8e756b0697c706268c168218c25fafb96c1b45ec8fa42f83b6a47edd9ef", "packages/core/src/storage/sqlite/user-input-records.ts": "81c80728982f5787ed794f76178a85d02333fad8015a44ae06b2d559bf73781c", "packages/core/src/storage/sqlite/user-input-source.ts": "364fd91c9b7e35bce8147fc689dd12e5474c1939f377c194daf4312da06c637d", - "packages/core/src/storage/sqlite/user-input-store.ts": "54e0c00c7f571b90425b40854a071d3bc28ec45f0a90d33b188c7fff1fcc8e93", - "packages/core/src/storage/sqlite/user-input-transcript-store.ts": "e2935b245a2f12c257869474b5c1433a4c4b614c31db26fb196f96cf3104cd7f", + "packages/core/src/storage/sqlite/user-input-store.ts": "43426250ff76511125458b2475277e8bb641ac726baaf8098ff1f463829af0b1", + "packages/core/src/storage/sqlite/user-input-transcript-store.ts": "82703c28ba4d7a65ce0e750f0c48df3af9c01a8f393d1f991452200e394dcfb9", "packages/core/src/storage/sqlite/user-input-wait-cancellation-store.ts": "a328d021bc6e9248b55713a989e9c344350638d9bbb672876004a5d73319bd19", - "packages/core/src/storage/sqlite/user-input-wait-evidence.ts": "2aceb4dd4ddb47ebf500a290511426c4ba98e12732c0bc304bde43733ada9c25", + "packages/core/src/storage/sqlite/user-input-wait-evidence.ts": "afbcac9846899868f78b04f4b99a49db0543e04ff1609703d55c75c6f82ea354", "packages/core/src/storage/sqlite/user-input-wait-migration.ts": "97d5dd499f2be2b6953c2d51df3ad8c94187480f0c061fbb29ff82fb22981248", "packages/core/src/storage/sqlite/user-input-wait-projection.ts": "2e5349e650c896eaa41b8eddf37b8ab4e413ab82f33ca8541e807895cd3b14ac", "packages/core/src/storage/sqlite/user-input-wait-source.ts": "d3f9a4650c3712cb87164b19c0f79bda539cc9ef08d58ce949bdb7b8f20744af", @@ -591,37 +591,37 @@ "packages/core/src/storage/sqlite/workspace-provenance-store.ts": "1bf929e07f7e6fdbeb2b7e384060d90cfc71bfcb78bc06b6cb93553aa4f02791", "packages/core/src/storage/sqlite/workspace-settings-store.ts": "d0a11cc8f8128fbe085e6d53ab3314ae0f5761bceab1c7a99875501e362f1d54", "packages/core/src/storage/sqlite/workspace-snapshot-migration.ts": "86d7765a7032c7c09fd6e4d7bf109d85906c1a2d295282e8b5ae0efdeb92b556", - "packages/core/src/storage/sqlite/workspace-snapshot-store.ts": "867c561940fa725042ec943c91d446ca2e48ff239c9122bba0bd36a108ca1e9e", + "packages/core/src/storage/sqlite/workspace-snapshot-store.ts": "037224db53bfbeb554154925ad15b30fc49bfa1c9948b8a32dc272bcd6d46d4b", "packages/core/src/storage/workspace-registry-store.ts": "8a81a23576828ce31c8d007dd7bad317b83d4fadda922621cf616a6190a35165", "packages/core/src/teammate-direct-input-types.ts": "36adcc76c3784d4deab1703515c99baa2710d446a9a57ef78677f7126ad13009", "packages/core/src/teammate-direct-message-runtime-types.ts": "1f360339eae2b8d9acd7d2c0e30c6e202cf6cda905b913e1fae69a17a1d461dc", "packages/core/src/teammate-direct-message-runtime-validation.ts": "e36990ab2444f74679576aa1df24f5247389e6d0d1c8d793a977c22fc76f443e", "packages/core/src/teammate-model-binding.ts": "c50677cf592d938fa047fc92632b5253b02d9e8f4783500e069aed251b9d92e8", - "packages/core/src/terminal-report-fallback.ts": "7d1e9a972ac223d6cc97abc278c6e711fedb21a6c88aa9f6edf4b80971e452a8", - "packages/core/src/terminal-report-publication-types.ts": "fed4d9a1fc9851311d32cf392ba8f01e889852b423f6b3ebaf2b202923d2c3c5", - "packages/core/src/terminal-report-types.ts": "2a4d142115aff8c79382858b18b2fd62939e0e6599a1cbffe569105a7ec4aab4", - "packages/core/src/terminal-report-validation.ts": "1ea22433ed62443887e0a5b93f980ba3034cb129218d387310ccffb4a0e3dc83", + "packages/core/src/terminal-report-fallback.ts": "91e137c39a0a0d71a4d462d98efb145ecb06fb14785975ec2e6de50f9310ff65", + "packages/core/src/terminal-report-publication-types.ts": "3d639e6ab7dfbf72b1e794d2055f12d6be94919e0782703df1d9af81a9a12643", + "packages/core/src/terminal-report-types.ts": "3c424a727100a9e4bad9315e7afea58e48fce30fbffd06d5324a9d0aed7b23f3", + "packages/core/src/terminal-report-validation.ts": "98bd065a0e83dfac3c233c31a96b6bb47f1c1f03cefc61ecc56e0d6861153138", "packages/core/src/tool-surface-types.ts": "c4172502d521d2bded266bcf004f484fd1e00f8baea2d402bb873fabd09ce87c", "packages/core/src/user-input-continuation-types.ts": "88097ca6f4ab147a5ca15fe7fea343dbd953929cce4d1a67d6a2ac0a73415459", - "packages/core/src/user-input-delivery-types.ts": "87032403b3569a9fcd38f3fd159c7c5b0de21e5c96de85bdf458193c7c312344", - "packages/core/src/user-input-host-continuation-types.ts": "3ce67cc39703388ef5ae73652b841d3bcbdb018dff1fd4883665dbd0fb9c89f7", - "packages/core/src/user-input-store-types.ts": "f2224eda7cec30599e5f696a10e60fa3725b973e16a4c7fae2fa9d06c033a5ec", + "packages/core/src/user-input-delivery-types.ts": "182970a6bbffe2d111af852a2120649666d37e093d05cbe851868610b8a65dc9", + "packages/core/src/user-input-host-continuation-types.ts": "cdce48e97bce849119b72c21c366e629bb8c42ee13ba2dfcdd20565c5b11ecf0", + "packages/core/src/user-input-store-types.ts": "4866cc26a52f2b2a7c90fe26dd1382a0fb68a34faf1d3a6ed340b09df845f27f", "packages/core/src/user-input-wait-types.ts": "33f148b1e4380fab071c8e27697fe115edae8f25a754473e09926216ccc78eb7", "packages/core/src/work-item-types.ts": "c09535e5a5c3fbf06fc6a3e45aee2ff42e5acf8c7ebbc728380a39deb2809084", - "packages/core/src/workflow-completion-read-set.ts": "dcba127a9472f5a06120454be774a89ccfe79a28528888db60d63da4837fca80", + "packages/core/src/workflow-completion-read-set.ts": "4d9b65d060f9e94ece08acf807e80c42ec22c4818c2844e77f691fc508dfafcb", "packages/core/src/workflow-graph-types.ts": "6da24188f2744534db5bd9f3ba82ad5658d352b845ee2af615149fc09f2af739", - "packages/core/src/workflow-step-start-owner.ts": "e7fb18ce988e9ef8cccf4315b80e8308c9598539c7f34a3644fff615c2ffab78", - "packages/core/src/workspace-types.ts": "de383a0ecc550d189038fb98602451ae21552aefd7310af509bc0daf22055753", - "packages/daemon/src/cli.ts": "1cac9f2d5e48fb651eee57598c0544306ff7c94a67ac11ef356313f340af3a03", - "packages/daemon/src/config.ts": "292484e622d5c699036a433eafb48728c03b06ba8b4a35b2fd570e7a2e693230", + "packages/core/src/workflow-step-start-owner.ts": "a3227511f92b8f59d0e9e81235510bd40df1d0bb4e01e0e3c411ae7c6343bf1e", + "packages/core/src/workspace-types.ts": "a571f338ac4e53c6aa0241ace5dad62a765bf41f8beed04bed4e3f1ac554aa5c", + "packages/daemon/src/cli.ts": "0e7a8389a94b0b46ca99c03c65c370fe4fdf053f250d214306f3bf280a1f58fd", + "packages/daemon/src/config.ts": "944f1a2921cbcb46aeb4b6473d00441f68d84de6ee70f92fdbf55882734f1155", "packages/daemon/src/errors.ts": "0c202132d2636bc5574ff9cb887e1b08d01aeb21f7718a1811a4987fae7dd775", "packages/daemon/src/features/ai-councils/routes.ts": "1b18338789add8fc3a33f6e074c1574ce9b3c3ecc2ca366481c71af404bc5874", "packages/daemon/src/features/ai-councils/schemas.ts": "5624a74348aa069a26969a6db0377c08a1284318815f1c891c7d58956b26c52f", "packages/daemon/src/features/automations/artifacts.ts": "4fcd037976c21bb1caaa72af4d35c4cc039a167a2e47692f6c177d145450933d", "packages/daemon/src/features/automations/context.ts": "2771425ff141acfa4023ebb382f73ae48533ae8b6388a9adff6adf153cb98de9", "packages/daemon/src/features/automations/recurrence.ts": "f50448ef85fed1e3bb64467f73851057944b2994ded351783faadb67aecc21b0", - "packages/daemon/src/features/automations/routes.ts": "5e1cc406ab55dbfbd9517739edbfb6a5e77dcb7a904989343db1dcc8edb579b5", - "packages/daemon/src/features/automations/run.ts": "74e95a7aa3519e5ac5ec0bc037911ab6ebf2a8ba753ecab7ffc4e8573dcf9b07", + "packages/daemon/src/features/automations/routes.ts": "d2fdf14c794b2064e3823db83d227ef3b1d5e29f24db247f8c04b0433ef739fb", + "packages/daemon/src/features/automations/run.ts": "8e11a38398f92de5e3467c7c4ea4489ff39042e0e0b7f028579bd6f49b4f53c9", "packages/daemon/src/features/automations/scheduler.ts": "c05ae6b5337a74c6a976026d8d5aa385fa7260cc74f689a7e2e758ae9fc131eb", "packages/daemon/src/features/automations/schemas.ts": "418478e2df13b35da3f8b4d1d8e1fa014fd8a4988a9a480421b91cd171a2a5c5", "packages/daemon/src/features/browser-extension/native-broker-shutdown.ts": "11703607f89682f919c15061da5e3ed2908301a83ad76753a76064cc8c248b5a", @@ -632,19 +632,19 @@ "packages/daemon/src/features/browser-extension/websocket-server.ts": "86a24d01c00c9fc4adccfde00026a81b4ce2a9c6870e38b65c842ad8dd16bac0", "packages/daemon/src/features/channels/agent-context-consistency.ts": "bd333e03259d48bb5e96bb1c7113d244281d4dccafc1e26b602a4a33a4f0e226", "packages/daemon/src/features/channels/ai-prompts.ts": "59a0ead8e1af686e91e59161dd5c243bb72b33395b434d966d4f618f4c6014c1", - "packages/daemon/src/features/channels/ai-replies.ts": "7bab06f8ed8652a7b435a63fa709c9f7432d06c5c1a9b2e64abe8779b09c357b", + "packages/daemon/src/features/channels/ai-replies.ts": "fe7a03d32afd67b9b3d3b8db9fdf853ae09616babb41f5641631830b2b91e312", "packages/daemon/src/features/channels/attachments.ts": "a7568c279217a794381c1f30da3350927a65e4935d2b466d2531d995b5e8a2d1", "packages/daemon/src/features/channels/channel-context-inspect.ts": "d3e50a3210a14810cd70e74f13187c7944eda1e1562eb595d701c6c072acfb2b", - "packages/daemon/src/features/channels/channel-conversation-api.ts": "da6f119ef9923d8295a9a2ea70fca1b9a175662b5845dceea0e7a23262c64d2b", - "packages/daemon/src/features/channels/channel-intent-selector.ts": "dd6cc8e64974bf28849ca2591c4e447a13b00e9d909cdf6c4efa3da19743a1fe", + "packages/daemon/src/features/channels/channel-conversation-api.ts": "290c34b809c2729958860787861e28c9e3076c47b4ba567d391fe722a20d2ec4", + "packages/daemon/src/features/channels/channel-intent-selector.ts": "b91e4f08bc3451a51fa077acda669554a0e8e4ea68b8ae339e41cce70d172c2a", "packages/daemon/src/features/channels/file-request-content.ts": "416d21cd97b90318a4765b6bf0bcffcfb77d8f02b29596b7818b5bd8fd2eef44", "packages/daemon/src/features/channels/channel-mention-materializer.ts": "eb032304905f018c6b417877c4d516740d883916de311405d1521999303da86f", - "packages/daemon/src/features/channels/channel-message-router.ts": "ddf4876d01211e84ed47e5f9482e7b965e48d1b69c53820c0d08f0008db0e448", - "packages/daemon/src/features/channels/channel-operation-bindings.ts": "1d78311c39f906be7ddd9ba344df39deae112423fc222dbd5cb77c82f849fc08", - "packages/daemon/src/features/channels/channel-operation-blocker.ts": "fcdb701619bf88ac7492840ffee6f5b05ddf18e3cffde0ed31cb8315a8883d94", + "packages/daemon/src/features/channels/channel-message-router.ts": "8df1887baa5b7035333164537a3e15a91b09074626865a78924e65afec81656a", + "packages/daemon/src/features/channels/channel-operation-bindings.ts": "359d54e9d5435b9ab914773409a255464d359d479f73e064497159414108881b", + "packages/daemon/src/features/channels/channel-operation-blocker.ts": "95bfccf8b14ac5b9a967a3e05a793d43f8c1455ce00fd79d55fdffa89674334b", "packages/daemon/src/features/channels/channel-operation-runtime-readiness.ts": "ab4117a439e7faed7ffb3ecff8e1dbf98f5e1a63d76c7c18c2dabc8038bb387d", - "packages/daemon/src/features/channels/channel-operation-sources.ts": "baca14921f760056a63ca622b687cd2b88f643713869f1ebb9772468bebbea37", - "packages/daemon/src/features/channels/channel-reply-budget-admission.ts": "c7b92085aebf2be92250ed93b01909820b97fe10841810087b72e7f3cb3f243c", + "packages/daemon/src/features/channels/channel-operation-sources.ts": "040b0ad8a6ab18ac8d1a278ddee18257a36392e861ccc9f7dde7c22bd164adee", + "packages/daemon/src/features/channels/channel-reply-budget-admission.ts": "1feaeb5b949aecbde7eb9739738cfa769185ab145c608166d5a9181d15dbce74", "packages/daemon/src/features/channels/channel-reply-continuation-context.ts": "74c021a928bbe20d1827e21c5705feb11775ac780184a81778d2af7d731263f6", "packages/daemon/src/features/channels/channel-reply-continuation.ts": "f6330383fcb99c8ea40d36d2f6c829b772e1d883c38c5e43cc2f82694308df0b", "packages/daemon/src/features/channels/channel-reply-dispatch-coordinator.ts": "bce030af5e9bc06805c80a5e0e4ec43838c93d50a3e9305d756c5433a510fd20", @@ -654,42 +654,42 @@ "packages/daemon/src/features/channels/channel-reply-execution.ts": "f1a073f5e0b5eae8933d5ae022ed71a7b873e0f5ef40e0cefad6b26a8bf220a5", "packages/daemon/src/features/channels/channel-reply-message-visibility.ts": "0cd7b1a6bc54b0e04e476d6c01190d03b0b8701621f762f7fcce017800558a63", "packages/daemon/src/features/channels/channel-reply-run-projection.ts": "ebf7f7cb35c82ae82f97c3c14e63e7a1f02cda9ba4a7e832151a904639a41cde", - "packages/daemon/src/features/channels/channel-teammate-availability.ts": "6b9dc9cd042b4ff982bff9fe769be30a249c355937f38f095a86c795cb5114ea", - "packages/daemon/src/features/channels/channel-turn-dispatch-executor.ts": "67558056c6361ebcee668a9981b4fdc595591775d56e477e97b2fdf99c9a78e7", + "packages/daemon/src/features/channels/channel-teammate-availability.ts": "60b9f6f88633db774eafedfb6bfe1d75cc29686921a994cb04d344a1653945e4", + "packages/daemon/src/features/channels/channel-turn-dispatch-executor.ts": "402e11964d30b4a2ff38fbe9f9a6f0a44aec51bba9098568b42c887f3aa3f275", "packages/daemon/src/features/channels/channel-turn-dispatch-lifecycle.ts": "0fd76fff028d7f0685c25be419ca3070c0128c2bf702f62273d658c2531966bb", "packages/daemon/src/features/channels/channel-turn-dispatch-worker.ts": "e8b164d5a1a50131fd70c34c93cdba273bbd1a958a1e68143f2c309e2b276f46", "packages/daemon/src/features/channels/collaboration-cards.ts": "029cbe6cf87bdeb2d40876a751804158426d459031ed22a7459cb269d15b625d", "packages/daemon/src/features/channels/collaboration-protocol.ts": "ad93c08a5eca76548bab567b82aeb7b381ace02f5d1e1e1e93fcfde76b1aa096", "packages/daemon/src/features/channels/command-operation-match.ts": "ac1c72782bf523ce5f4c594872e19ec71b81304e7c15e71f93228e809cd1af75", - "packages/daemon/src/features/channels/context-compaction.ts": "628ea95bb4bece7bc5ff25ec140d763801f51aed17c65189965fbff6ebf87f97", - "packages/daemon/src/features/channels/context-packs.ts": "4225a1423128ba3d1d59df8315cae1ed414880d207442a86a6bf22689bd10d1b", + "packages/daemon/src/features/channels/context-compaction.ts": "6041afad9640b82c26d2f9c3a747834539177ac44d12ad85ae243f720e3487c6", + "packages/daemon/src/features/channels/context-packs.ts": "63a59763f490001d3ab87a2d958c912be0f4a7013c9d3bbaa3ec11175ef46faa", "packages/daemon/src/features/channels/context-processing/condenser.ts": "d9178851fa2ab229ff3b6d4b254140578b54cb9411de9a11b0b746721ce07eb4", "packages/daemon/src/features/channels/context-processing/context-segmentation-model.ts": "a6a335ea90a18ebd0a71a57797a4d459c6f4c62120d770e879b5aae72c4dc6ea", "packages/daemon/src/features/channels/context-processing/context-segmentation-policy.ts": "38299ab993328bc2c5e49781588818827d63025384177db8b767b901795540d2", - "packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts": "100bb0de5da839134d9ea8ceef67e995948e546e6c0ca19b8a0c1bd03e840029", - "packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts": "6252b4d80c6fadfea45886731e7b133bb702beae5a6b97d0bb72ddc2b903834a", - "packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts": "8b843f9c2e3d674c821dde7ca6f582e539e836836faa95c0fcb6e33ba374e4f4", + "packages/daemon/src/features/channels/context-processing/conversation-context-adapter.ts": "0888f830c555b0aac090c56d35949a23291d5b7de8477016166eebb713b3f9e3", + "packages/daemon/src/features/channels/context-processing/conversation-context-projector.ts": "a231a01f4c340de805e23a0fff546f6dc8e776e440fa17b662b35f950b88b3e3", + "packages/daemon/src/features/channels/context-processing/conversation-summary-coordinator.ts": "2538d13117e63822a906a06f438df7b421ee3640ed25c046f562aa4a8a0017bd", "packages/daemon/src/features/channels/context-processing/conversation-summary-execution.ts": "c6b7a40f0d3df9e49f3dcae5a4c4f4015df033ac462778b1045ce61a7583783c", "packages/daemon/src/features/channels/context-processing/conversation-summary-model.ts": "9304eaabe12f05386274af7a511f7cbb28ad2fcbd125383050fc7804eaecdc5a", "packages/daemon/src/features/channels/context-processing/evidence.ts": "aa78762ea234d4d8de877b716cd5a148929f64165103c607975833a066ce80c1", "packages/daemon/src/features/channels/context-processing/inspect.ts": "0ceee4629b6fbe69acac9d38c5a1fc83714257149c8e6978b3055c065bfde254", "packages/daemon/src/features/channels/context-processing/memory.ts": "c5bd6d3ab6faabec48a62930d407bed76674542222cd2a6725e52eaae013ff14", "packages/daemon/src/features/channels/context-processing/policies.ts": "e7f54cd6b7980c10b4f45e543d2c6eab61ad039f655fe0a70efefd5c7651ae7d", - "packages/daemon/src/features/channels/context-processing/private-chat.ts": "628580a19117c2c181cdb6b94fd7a80290d8c774f51b252d4b815bfc2c62aef6", + "packages/daemon/src/features/channels/context-processing/private-chat.ts": "3aa6e3e3d20da656e4fce89dadfa4b2aa5b517fab6c79965fc3140a29677900d", "packages/daemon/src/features/channels/context-processing/proposals.ts": "1b7b2c913bf997febdb28732d611320f9b9bcf886f573ca173f2c33276ce5439", "packages/daemon/src/features/channels/context-processing/render.ts": "309382232cb4b6d961720124aa8baaf7ede2e5e8e62f270aa95f7a5aea0e36e3", "packages/daemon/src/features/channels/context-processing/sources.ts": "fc981635be70968b6da3b41a40417e15e055f9808b72a9ac23d888330ec9de66", "packages/daemon/src/features/channels/context-processing/views.ts": "fb1f728cfc2857b0e0c28d5a3bc31859155d17e4511ec51b4ee66837ef65d1b0", - "packages/daemon/src/features/channels/context-processing/workflow-steps.ts": "4926a41072b1e4f9bce36470d84b5663eb3946392e0d36c9d54a1025e1e9d4a8", + "packages/daemon/src/features/channels/context-processing/workflow-steps.ts": "c709459bf372f3e68afddd30d222ebc33fa7ccd7d26c8e51868b8628d0689804", "packages/daemon/src/features/channels/conversation-item-messages.ts": "5ce48eb5b6690f1503e9505b1b3f95b6d78876f27f423de1fd0a12ec4d01beed", "packages/daemon/src/features/channels/conversation-surface.ts": "9a7323525756055e4d323efda0df41d3a225f522a4c7ceabbb36fed1c8d536b1", - "packages/daemon/src/features/channels/conversation-thread-resolution.ts": "1cfe6d64882f765cc3531cfcde93ebc9fd0a4863ca6ecda027982de3b7eb1e56", + "packages/daemon/src/features/channels/conversation-thread-resolution.ts": "870a25549c8d1c5409d7bd5ed7fca53e991756d653b0a324b5eed21763270d52", "packages/daemon/src/features/channels/coordinator-workflow-narration.ts": "8f51358347c3313c26347caef798a0a961df30fe4a93e4c0b86121ebfd466719", "packages/daemon/src/features/channels/council-slot-resolver.ts": "ff99a1e13356dc657a47e4572031670b757113d39c7d5b09200b9f896b7a9c6b", "packages/daemon/src/features/channels/git-status.ts": "1ad9b35da6eef39f0295e1c7df195224275a8bdc1cdc439cf983cccc0e27ac29", "packages/daemon/src/features/channels/ling-agent-auto-start.ts": "00aa05caceb562f8baecdc9f72110b18bffae3b3ff9cda09ad960c7a38c2c8f2", "packages/daemon/src/features/channels/ling-agent-executor.ts": "b447b7db0fe91929106e70ea43f42fc4c096c785b03350d5d6dcbde149aea014", - "packages/daemon/src/features/channels/ling-agent-turn-envelope.ts": "3285e46175031885d9938203b42b458a1e1ecbb6bef7b1a8292fdf914be0138f", + "packages/daemon/src/features/channels/ling-agent-turn-envelope.ts": "3efd4500cc88870563d56743da0837745afa05937dbffaad0838bf10e746ee80", "packages/daemon/src/features/channels/link-previews.ts": "d87e359dc8ac94dadf6263519e722ca703f800079244ee2effbd1095dfcaf73f", "packages/daemon/src/features/channels/markdown-text.ts": "5483a08d2073945244b7a6f078f4b1f95606ffaad991add0a69fc6de77e8590a", "packages/daemon/src/features/channels/materialization/budgets.ts": "2e248f1c7ae5008d6a4522f68edddd1f32c30b627030927f60b34c0ce5bbbb47", @@ -710,16 +710,16 @@ "packages/daemon/src/features/channels/model-json.ts": "2b0dc4ff06147a9106483fb149e9b21cc220f01f582021fded29921be09bbaee", "packages/daemon/src/features/channels/normal-terminal-report-publisher.ts": "262fd87dc96cbf0716e6f0e1745dcf6918e1dc631e819fa0a6c11f8fb4bec69b", "packages/daemon/src/features/channels/observer-bus.ts": "e3d093c5e6213d9acd1f563c117703987f504e5212841bdeedb0d66f8f87c399", - "packages/daemon/src/features/channels/orchestrator.ts": "9d424d0684c4e0a04e30446dc052582e921479ca716d5f6a04e6c916630878e2", + "packages/daemon/src/features/channels/orchestrator.ts": "a4f54f6eb4ed8e9dc60c20800ea0d5f4117c7f33bfcfcd930d9161e0fcc47af0", "packages/daemon/src/features/channels/plan-mode-guard.ts": "0dbdc20d940ff1d690035257f211fb4bd591868a10c330cc712512107a0eb61d", "packages/daemon/src/features/channels/playbooks.ts": "ef4e574c9af4ad53adb802f56f8d8eb5fd04c54b7e4b29bbe6a89bf270c7bb62", - "packages/daemon/src/features/channels/proposal-workflows.ts": "76392e04dba3786915523c415e87e048534ee0a8799d98f4c7fce4cd17350e8e", + "packages/daemon/src/features/channels/proposal-workflows.ts": "39c457a8696fb1f16e6b9f6f61caea91ce400419254e3b5e140e18d1f0f80df8", "packages/daemon/src/features/channels/protocol.ts": "579f8a51955e97269079d82fdef4ef13abff8906df27169d9ede9dbaafafe7c0", "packages/daemon/src/features/channels/provider-runtime-observations.ts": "ced38925cfff85b18274a53a47e2f8b14aaf57d0876d3cb30267ec0823fb9c31", "packages/daemon/src/features/channels/response-language.ts": "73447206ae33101ecfce6b7a0431ae30ed92d6b12033cccd9cdae3a2facc63ed", - "packages/daemon/src/features/channels/routes.ts": "784b05c0e049060541571047f0114b4a2068b2479ddc8b464301b7f8d7ff2f70", - "packages/daemon/src/features/channels/schemas.ts": "683cb35b4ffddd8d3063f0ee5a10f3384f6cff8a02dbba4c0737d94d6af92255", - "packages/daemon/src/features/channels/smart-scheduler.ts": "db812a1f5dcff54d2463267af41d8863d046a0784759e1f063636577b8eb7761", + "packages/daemon/src/features/channels/routes.ts": "f064e7048b0776e4246a352bb16eb4950aeed155d69d594b7d5181c94ad96791", + "packages/daemon/src/features/channels/schemas.ts": "9a7aa34f73dd60c58ce86d9f16e9f8ffe95728bd316f1e0477b59faaa68e95f1", + "packages/daemon/src/features/channels/smart-scheduler.ts": "6d4f691cee5414729900c6dbaa348b10898aaf16a3e80ab22b81e039a088b6b4", "packages/daemon/src/features/channels/target-resolver.ts": "2f718c686e9a700e935df459ae301f29023bb1df102268c3d6b6c949e55fdc5b", "packages/daemon/src/features/channels/team-authority-ledger.ts": "434df4c7742815ffafe3157419a834ad662453acc0a76dc1e269b594c3f1db22", "packages/daemon/src/features/channels/team-experts.ts": "7e2dd822bf9e5320798f34c1ea4b2ceb7a1275c1c43d7484d061c93155de77cd", @@ -728,23 +728,23 @@ "packages/daemon/src/features/channels/team-snapshots.ts": "b6b1afe604ee7d6490af8f7471f03beb76162babcfb5733036634a3501956524", "packages/daemon/src/features/channels/terminal-report-coordinator.ts": "1dcc9951738429ecc4b10a39e608037e26583205c1213850298a8e46eddbcba1", "packages/daemon/src/features/channels/terminal-report-eligibility.ts": "f5739bc225c2113a5175ccfe7913191651be23401f77641fbcae59c00da0761f", - "packages/daemon/src/features/channels/terminal-report-evidence.ts": "939ed4427d4cb8b2a46c5226cf42bd5384397fc4a23c718cb30fda07d884765f", + "packages/daemon/src/features/channels/terminal-report-evidence.ts": "cbd8fa5513afad573f5b57b86651fd80ff9074e98e1ee3923c17905e70944d9d", "packages/daemon/src/features/channels/terminal-report-interruption-risks.ts": "3f00eab0eb4a437910d7aa0a054439d303eb7a40bef5e41040d58e9cfb9a932b", - "packages/daemon/src/features/channels/terminal-report-prompt.ts": "56756b3cede68214b395abd07c2ad55e44f6d15eecff425d78ec4e0c444d0c3e", - "packages/daemon/src/features/channels/terminal-report-publisher.ts": "2960cc505a842cbef0fb08985809ba23f9185f822b5ac08e6365dd1f521c3a4d", + "packages/daemon/src/features/channels/terminal-report-prompt.ts": "1db0dac85b44add7a48045d1864f6b89017eda3e2b58e37d823d7ba07b613069", + "packages/daemon/src/features/channels/terminal-report-publisher.ts": "bc18bba0104a3d601d9f5c6716e73a2f6b1113036936997d0db7ae2552d34cc0", "packages/daemon/src/features/channels/terminal-report-recovery-sweep.ts": "96d165cce43564c26bc4709bba723b8d233630d476df3ca0d28ff41ce9937bb0", - "packages/daemon/src/features/channels/terminal-report-routes.ts": "a64c8fb850709859ba959bc869b6afdd398f6681304871cb0eabf669adc0efe2", + "packages/daemon/src/features/channels/terminal-report-routes.ts": "47a7a9a5f03e62314bedc6c2328fb57cb9ae7975f252168534cf776a901d86b6", "packages/daemon/src/features/channels/terminal-report-synthesis.ts": "316dc1494ab7f4c84f480af7fc35931606f83c7986810c944181da20d059b011", "packages/daemon/src/features/channels/terminal-report-verification-expectation.ts": "d2dd5996e5737f5c04a80e740788af17c6fcf15844e66c581564e1df2ea2b1a2", "packages/daemon/src/features/channels/web-tools.ts": "5f2e4924c937800e61dbe48331b1fb5ed8a12b84df879a7ad07d7d35c4daff83", - "packages/daemon/src/features/channels/workflow-completion-gate.ts": "afd565370b725b0127361d690629e5d4d97c26ef45110d38dbcde7b51a260bea", + "packages/daemon/src/features/channels/workflow-completion-gate.ts": "bf3d87a7fc188c73b98f54c94113e3e9b66f908d0369b386ba5912ae7da5001a", "packages/daemon/src/features/channels/workflow-completion.ts": "37f9d0624fd731cb818aded165bdf95489b45cac9f24bddc4e3a532320c54550", "packages/daemon/src/features/channels/workflow-coordination-routes.ts": "d1a80d798c860329b00df8087629364ec793296b12b70ae87a5427aff8ce8e5f", - "packages/daemon/src/features/channels/workflow-evidence.ts": "42df458ad6ffa5cf7d86391bf7888cca6e24d228362a74fe29d7a7ab79ea8f63", + "packages/daemon/src/features/channels/workflow-evidence.ts": "caf03cebd3cd64f60df8acc28d87b25e4cb02a438d2d854176fabc644b6e82c1", "packages/daemon/src/features/channels/workflow-fanin.ts": "8009d196ae1c65e668441a9d30fab6183ff45d1bdb4876474886d78652c714ca", - "packages/daemon/src/features/channels/workflow-graph.ts": "f22486028609266b398083cbabcb856b0c1755bdf80fa9af707a346c67435b20", + "packages/daemon/src/features/channels/workflow-graph.ts": "f48c07561fcb26db643d6cb3e46095ee028e66cef28519c026426ca8e0879e70", "packages/daemon/src/features/channels/workflow-handoffs.ts": "4f235fd20bbe3931fa427031a8ed423802fe74471b6d37067573419292f4edba", - "packages/daemon/src/features/channels/workflow-operation-evidence.ts": "e0a18bb022cdba59bd53c8ded1abe54ebb1a889d1c7787ac0e557550c4f30f91", + "packages/daemon/src/features/channels/workflow-operation-evidence.ts": "2d6ab5d0258b30da37a94eb9f2d46044cfd04b28f188a618c8eda87646ce1829", "packages/daemon/src/features/channels/workflow-operation-ownership.ts": "3bd869fcf7dddc6dfbcc99f19c9179fa3b9b10cd557890f2430755e60c75e60f", "packages/daemon/src/features/channels/workflow-participant-provider.ts": "747e1a3b80e7cbe4b0a08efb64384dce05f0402ee2271e44cc502cf1ae836145", "packages/daemon/src/features/channels/workflow-participant-run-profile.ts": "fbf9a503eb07498be4e9fa9c9c96c524a1cccb5c7fac502adfac0335db9bc6bb", @@ -753,13 +753,13 @@ "packages/daemon/src/features/channels/workflow-source-turn.ts": "04075145189f54e1c613f3b74fcd2c1600f91903cbece070d9a08843612a9125", "packages/daemon/src/features/channels/workflow-step-finalizer.ts": "79ddde1261ac6b85117d8a922296fe1236af42b493e8876873c441a3f0b57dd7", "packages/daemon/src/features/channels/workflow-step-narration.ts": "3e268313f0b15fb25f98b938ed8d8306ff530ad41d64c1ce7d416f8d1b9a185d", - "packages/daemon/src/features/channels/workflow-step-packets.ts": "61beb2a0e7c334569834f1aa7a745c227cad6b5be36d9e07ef5e3c199d27db12", + "packages/daemon/src/features/channels/workflow-step-packets.ts": "33785072bfb6f65b46a2299c1ddfbcaa62fed9ee0bf623b3279f3f38612fc410", "packages/daemon/src/features/channels/workflow-step-requirement.ts": "0f9a90f9938ee2b1e9db21dd7c0ab81f226a88d4a7fce5def2dafea046eec125", - "packages/daemon/src/features/channels/workflow-step-run-policy.ts": "381d535e0d2d9d3016c78b21998a50bb400371cf4601e5acdcb144a6ae353b1f", + "packages/daemon/src/features/channels/workflow-step-run-policy.ts": "77735a3334d49064963828aa939dae6f4416d49b0eb108f25a90e2d0b7ce0114", "packages/daemon/src/features/channels/workflow-step-user-input-history.ts": "6791ae92720516017b3aa63920cd11b7a52346d4d9439866e08a32a2b5eb4f99", - "packages/daemon/src/features/channels/workflow-templates.ts": "cd0b4dda0a38d8b68607e74c39f8379a5dbbce840b4d7213272e0353acb8905b", + "packages/daemon/src/features/channels/workflow-templates.ts": "1fc8411e5f9a04074eec99bd4d121e5379b7ad9512c1c4e41fea3d484728b715", "packages/daemon/src/features/channels/workflow-workspace-change-recovery.ts": "5c184ef810ed2e9d233397ab898e1cbb02488817d3f093c448effe80b7aef7b8", - "packages/daemon/src/features/channels/workflows.ts": "c5048ccf17010afdb847c94195c98106a73ab3ecbda4670b160c60a960136945", + "packages/daemon/src/features/channels/workflows.ts": "91a5182710448445cfe7e569b7e0dd43c43cff98354b24ad593c4c9a8e3e1a5f", "packages/daemon/src/features/channels/workspace.ts": "f5c774190ab60fd62497ed7a47a1cff2db2f7c1e72e4ada7635d39d7acb55454", "packages/daemon/src/features/computer-use/cua-driver-producer.ts": "4dca9183107d3733817f0fd292250fc7b1139299d95a17b6750c14b18dfb49a4", "packages/daemon/src/features/computer-use/desktop-bridge-auth.ts": "c0763f51533d784ad0826c7f8a7a449488f5001be9c632e5bac8de68c96b08b6", @@ -787,26 +787,26 @@ "packages/daemon/src/features/conversations/normal-conversation-context-budget.ts": "0fa956708592a90284886dad4fcee424501470eb52b73a0183c157cfd054c0b0", "packages/daemon/src/features/conversations/normal-conversation-context-hydrator.ts": "79726967bb5bd529a8aac724cf7a1d03e954326f1f837d7257475e7c5e557c26", "packages/daemon/src/features/conversations/normal-conversation-context-turn-candidates.ts": "c9df3ab1d85a4dc7fe54a61bdfbd17c7a72af03120c4545118894e89228631fe", - "packages/daemon/src/features/conversations/normal-conversation-context.ts": "1072aad8944e5dac14f1556b2cf37ec7f33d8fb9ce2985c29840e0e171e89d24", + "packages/daemon/src/features/conversations/normal-conversation-context.ts": "f21721db6eedd229e1c529c341aa96cf6728f2006a703bad985d744bb1b99967", "packages/daemon/src/features/conversations/normal-conversation-continuation-source.ts": "8559deb18e6e7be77efbcf77421598a7ab99f68bec831eb7572b299177b86c4c", "packages/daemon/src/features/conversations/normal-conversation-fork-capture.ts": "d073367c622d5c890e6f9cfc95869422f6c7b73e2f7934281df4447c0a8d7eee", "packages/daemon/src/features/conversations/normal-conversation-fork-history.ts": "bf542b8dc37b4525ae51623e1d906570be3324a14de793d61d61fd98ab378cc3", "packages/daemon/src/features/conversations/normal-conversation-fork.ts": "6787370c9991b310878d438351800a29d9a3de3b2b335b957e27740cca551af1", - "packages/daemon/src/features/conversations/normal-conversation-input.ts": "0dbcf2af15edc32e893c5e223eca200505248139d17e617cde60aa0ba2f43914", - "packages/daemon/src/features/conversations/normal-conversation-prepared-execution.ts": "e1b4f74d4045f8e9209f8c936261c291fbabcb73f197dbcff8477d358a4439c9", + "packages/daemon/src/features/conversations/normal-conversation-input.ts": "6f82b25918ccbc18d577c673722c43e607266fd07780642bb8fa1c03d9be95d4", + "packages/daemon/src/features/conversations/normal-conversation-prepared-execution.ts": "9232d903a3add73db4023de238e80fcbbbc7c8b95765ee52f6d0dd8f51365c66", "packages/daemon/src/features/conversations/normal-conversation-prepared-provider.ts": "e5b8e16d21cd7eb6790ea500ab306a656a68cf42fd5f1554bfde84e064560ff8", - "packages/daemon/src/features/conversations/normal-conversation-prepared-request.ts": "61945bfcb37f64ce981c002da727c80ec2cf436fa9bbc339d10aacea01e04c71", + "packages/daemon/src/features/conversations/normal-conversation-prepared-request.ts": "0f20a1fed4427ee107f95127a1f9e1f73b79ed359ffb2377ec625e769f214bd7", "packages/daemon/src/features/conversations/normal-conversation-published-message.ts": "40bf40330764397c51d5e8c992a6aed50995550b47c10f7863569c6785c79db0", "packages/daemon/src/features/conversations/normal-execution-context-snapshots.ts": "5c0f874f0ef3f068d74d498b197bd61a9aaa8126c5dbfccd2993906940fb6fb2", "packages/daemon/src/features/conversations/normal-narration-publication.ts": "32c90f62b1f12e1c133f7ec1b6e36bafe4d40cf5d5acc7dc958a840ba6fd20ef", "packages/daemon/src/features/conversations/reasoning-summary-projection.ts": "8cc99c5a165519788c73e1dfa13011300fb6745393cee1fa7d0f991ded1bd0d1", - "packages/daemon/src/features/conversations/routes.ts": "892c92eb852e0d25433a4761054e1bcc9579645c0135e8294b72729688410b93", - "packages/daemon/src/features/conversations/runtime-projection.ts": "8847e7d4155020c3e03a112403e7d4f1c0b38c212057c87549ea1b30f4b9943b", + "packages/daemon/src/features/conversations/routes.ts": "62120da8bc9bc780d2f67d2e68e2c9bb6da2ebe3dff5625a767d2cc029c16c68", + "packages/daemon/src/features/conversations/runtime-projection.ts": "364daea4f10259df7539b7fd6b2512c5e7a792740359117cee0119f5aaafc2b5", "packages/daemon/src/features/conversations/submission-receipts.ts": "62106e6868b5ddcfa38cc04a72e6fbd6de73c3a6541233c2216758bc5e8e8aad", "packages/daemon/src/features/conversations/supersession-projection.ts": "a4d7def2f913c1fa480f0450ff53cbcdf903b9a9ccb92d642e7d807fa47fe5c0", "packages/daemon/src/features/conversations/user-input-transcript.ts": "3a577903b73200eccb920be82816998108c94714fda64db8081fdcc6fdc2a595", "packages/daemon/src/features/external-work/actions.ts": "7d31212c18b852169a1ce6d21011aa4b7dda351b9023998b240edf37791ec47d", - "packages/daemon/src/features/external-work/background-scheduler.ts": "91c831cecf099674ae8eea11d9a25872ddfcf0232cd2618cb534fe15b43ab3ef", + "packages/daemon/src/features/external-work/background-scheduler.ts": "1669044979821889f86245d00c7ceabd10b516ac6c0f6c6ebbc3c1746fec9e19", "packages/daemon/src/features/external-work/background.ts": "5565af9421a06e72d9588f45d984028c777ca007ff16fb4a3e7e3307ce972300", "packages/daemon/src/features/external-work/ci-triage.ts": "0721d465dacbe5978d4049593f6ad2905a10032a4617b4071c21cf4a46689bf1", "packages/daemon/src/features/external-work/imports.ts": "379a2d8e3da04dbeb1a448ed711cf46e25aac8b8d538868b0a96f3a77250c203", @@ -815,43 +815,43 @@ "packages/daemon/src/features/external-work/status.ts": "291ab52b7f097912852d2e618e2d0dd32477d5ac77c34177d1cc618ebdda9741", "packages/daemon/src/features/external-work/sync.ts": "4480c99f53d28af04b4ab5f128ee90c8dcf9307eae1c3ac3a296157ce86dc8b8", "packages/daemon/src/features/external-work/workflow-path-admission.ts": "0570f17b9a37cedd16ad80288282ad640042d7ffd19a12d24e385911169a8e55", - "packages/daemon/src/features/external-work/workflow.ts": "4214b766acebbd4dff5c034482ff0776d81d680650bf8385bc636c91c3a4f7a9", + "packages/daemon/src/features/external-work/workflow.ts": "ccafdc9b0f14d70d28c0947f0c3a115d4fe2cd1d9940f3e2923f40828d740fd4", "packages/daemon/src/features/governance-notifications/private-delivery.ts": "59a01ee8f281bfc0fc8f405b610c6d8d96bdd61c5b0b416d9dbcbaf4169aed44", "packages/daemon/src/features/governance-notifications/routes.ts": "7fde7f37f55ade5b8d3bccf092695398ab9c754678c5e29adcbd5ba89140bfe9", - "packages/daemon/src/features/harness/continuations/coordinator.ts": "beed346e7fd80c59f4cfe1633b44fefae173ac33364a4410cb54d8f02c33033e", - "packages/daemon/src/features/harness/continuations/daemon-recovery-coordinator.ts": "d0476bdd0e9e536354aad50e475196729d2693544f8e55e72519b2b780c6fdad", + "packages/daemon/src/features/harness/continuations/coordinator.ts": "87c475951d88e9a4980d1ac5841c277c07e297b08f7cdf4a045d3d95f2ae49e7", + "packages/daemon/src/features/harness/continuations/daemon-recovery-coordinator.ts": "a77520d2d821b38140280c639ed680cf87301539b79675ba8586769164d70406", "packages/daemon/src/features/harness/continuations/host-compaction-resource-boundary.ts": "4135c1d500f67ed15253a72c4a1d7af7fbed973443e94a540347e46d4f67ec19", "packages/daemon/src/features/harness/continuations/native-handle.ts": "5017e33db503fa3b126c6814c7645778ea85de5bc7498ecbdc6a17a49beaf78f", "packages/daemon/src/features/harness/continuations/reconstruction.ts": "55c744559208002cc14247fe88ea4a7e65d323050b1d6302f96ee7b32cea3eed", "packages/daemon/src/features/harness/continuations/replay-package.ts": "0dbe53354aaedf91060eaa3b29e43105b6a80082c03b5dc47eff0621003d5844", "packages/daemon/src/features/harness/continuations/run-compaction-commit.ts": "3e2abc99ca6400f2859c6a5693f403ad285ee3f3d58e56f222ca4004f4b73005", - "packages/daemon/src/features/harness/continuations/run-compaction-model-view.ts": "998b863a3cb371cacbee7f9e5a9f367d98a287ec9699aac77ec8501a4d1c3b1a", + "packages/daemon/src/features/harness/continuations/run-compaction-model-view.ts": "f7ca0a64bf5d915a1a9906f2ecd4fc4af39a6e90290decf8c68fc022c8b5769b", "packages/daemon/src/features/harness/continuations/run-compaction-progress.ts": "9e7518f9c11c628de0b4c7c276f5c0f973802bcbe90ceb9f5bb8a83f76919aa7", - "packages/daemon/src/features/harness/continuations/run-compaction-recovery.ts": "20746a13a4c1ed2d2a7f9bf00350ed502caeb872249fc82f89e8eecf50bdc79a", - "packages/daemon/src/features/harness/continuations/run-ordinary-progress.ts": "1a72d55d5cfbdf1927b7f960f26215c390bdcc595f62920393c274fb74a8327c", + "packages/daemon/src/features/harness/continuations/run-compaction-recovery.ts": "32e8b75bc1078eca19c4a4b5e95c3d317a332dc65d9d4ff4a63554ba61ca62cc", + "packages/daemon/src/features/harness/continuations/run-ordinary-progress.ts": "e8fcb32e606c0563db410628b87289d427fe380a5bd345440ae9a16fcf2687cc", "packages/daemon/src/features/harness/evaluations/rule-grader.ts": "7e5bca14b9284681d9d58575ed21da387dec9ed7ce9878a37df6b3484fbbb751", - "packages/daemon/src/features/harness/evaluations/scheduler.ts": "bbc845b6565d7c72b67116f3fb33dc2520870fd35a2f325d64cdbb83a73873f2", + "packages/daemon/src/features/harness/evaluations/scheduler.ts": "d601152dfef1eebcb14d50515b6eca21b0ce45f42965aad5d2d32cdf08bcd9c7", "packages/daemon/src/features/harness/evaluations/task-worker-input.ts": "64277d951a33959c00c95f98dcb1549564b38f87551ffdd693e38e524384e157", "packages/daemon/src/features/harness/evaluations/task-worker-prepared.ts": "e9bd1282bd01b338ea7a7250568481e9b1d905ec41bd3def6cf9c705604bdbfc", - "packages/daemon/src/features/harness/evaluations/task-worker.ts": "3ec2f13a285b68df3d7cc452514f69cc3583a1a165c4c7610537e8a04e9204fc", + "packages/daemon/src/features/harness/evaluations/task-worker.ts": "76dd7c6a7ffe8c5209657ffdde1c41945ccb9e456e1b202f3186325a0c8b9e34", "packages/daemon/src/features/harness/evaluations/qa-model-json.ts": "76c15a08edc329b90a3b91c5fb27bbbd5864306db4d92aeef2adfa3781976518", "packages/daemon/src/features/harness/evaluations/qa-model-preparation.ts": "fe1317d66189a038a45fe0cd54afe3289fc7bd6eb78167e8d963cd0ca23270eb", "packages/daemon/src/features/harness/evaluations/qa-model-owned-batch.ts": "f4cdc44722228d9689acaa66e36c3a8016680c4bbd23abe06c318f892ac030e0", "packages/daemon/src/features/harness/evaluations/qa-model-transport.ts": "19d8ac0c23fab959bf1a8f83b0dfd28e3916d16150301728165172eaf0e4abd1", - "packages/daemon/src/features/harness/evaluations/qa-model-coordinator.ts": "36e711384f52624aa4fe04452cbc5c28fa006eb4196b0e316d28eea5a87b5202", + "packages/daemon/src/features/harness/evaluations/qa-model-coordinator.ts": "e5bdf37d3472039ef4dde4bb5797af23e72dda2cc4de4cedc17fcb9a1922656b", "packages/daemon/src/features/harness/evaluations/human-evaluation-authority.ts": "7f48e6b18c57c30f82ddf0809d2396014750d6ca9a58dc43a44383db404fbec2", "packages/daemon/src/features/harness/evaluations/human-evaluation-protocol.ts": "d2f852f60e958f1e3e1526ab2de8c59d6ec79dd0ad017b28cd818cf884de995e", "packages/daemon/src/features/harness/evaluations/human-evaluation-report.ts": "4c7b694c112b4382b8e55a8bdc580358f81ce36a35a57c99f2c4fe936ccad2cf", "packages/daemon/src/features/harness/evaluations/human-evaluation-routes.ts": "cc7b32661c1ddfdb37b70d721f7f0ffc83d711fbcea58cbd82cd3a8740a547ba", "packages/daemon/src/features/harness/evaluations/human-evaluation-service.ts": "84df49728fb7bb627e5c3b8746c5705f9ab185a91ba8fee5994ed3697cb11dda", - "packages/daemon/src/features/harness/index.ts": "51a460ce013dac143c86ed5aaea07eceb585dbd257f98abef9a4d3088c40d991", - "packages/daemon/src/features/harness/tool-execution/broker.ts": "974be67e627536392c4b949930bbc9648848b4cbc170248248caf01dafc24cc9", + "packages/daemon/src/features/harness/index.ts": "20c4e346b6f0ab6ad75710c349b5b74d2075ebf9b3e45598c180af9c4b84e1f2", + "packages/daemon/src/features/harness/tool-execution/broker.ts": "ca66dc57e0d02035eadbf52ada755392b7c374106eff1e8455520103de634cf6", "packages/daemon/src/features/harness/tool-execution/executors/file-mutation.ts": "c7cc9d5d4413d578ce67cd94b1108e457c3c7d45cd7406a2334579aca59e02b0", "packages/daemon/src/features/harness/tool-execution/native-file-mutation-completion.ts": "fe603c2c2acc0d8b6d98b23eb58943df85c50b641e0d80beef4e7f8080d88b0e", "packages/daemon/src/features/harness/tool-execution/recovery.ts": "552234d83873a515899ab92d23c8f46bb65efe45d040f41d8f51eaf6545e891b", "packages/daemon/src/features/mcp/broker.ts": "5c002eacadffddf3ed543bb4bc7b59ef77297560d41e66aec40449b9fed2f5b4", "packages/daemon/src/features/mcp/host-operation.ts": "4dc4e5e9186f3de7c2f45ac9f1b38577f2a0792b6cc96bc25ee9ba0ebd3a7405", - "packages/daemon/src/features/mcp/host-stdio-transport.ts": "ed593ebe134259d65258fa95b889493c2c2efbc27b02c1ca1423262d10cff03d", + "packages/daemon/src/features/mcp/host-stdio-transport.ts": "861cdd93df0fa0626d5693a0fe51189fb8a2172476f63d5a0030fdd57944ab9f", "packages/daemon/src/features/mcp/office-local-mcp-operation.ts": "9dca573191725b5d539c0eec2caa3e3d8b4d91182186c35983a21170eb07e903", "packages/daemon/src/features/mcp/reserved-ids.ts": "2f7a86de45f2fd3298b508b8871cd322c5a20a8957cd8d7ef1757d49fe5602d3", "packages/daemon/src/features/mcp/result-authority.ts": "49f7321f481445d4461f086fe021df3c7f9aab696e691ee7a5e8b1e68215b04a", @@ -872,17 +872,17 @@ "packages/daemon/src/features/media/conversation-targets.ts": "22c436a20942e5fbed4b5b3642cb8ee8b2bdcace8782b0a9a4de69089a6c698e", "packages/daemon/src/features/media/conversation-tool-handler.ts": "60236856b820f749c527ed65c0da25e18266db941d7468cdd6d4d20af21204b5", "packages/daemon/src/features/media/executor.ts": "cbb7ffcaf793667ace363aba280a53f15b2365088a193a43cb5e3adfa9582a2a", - "packages/daemon/src/features/media/guarded-native-transport.ts": "d8f02d19f49d88dae35624ca0db0ff8f91592c98e4f34cb4aa8bd4ca1f9fdf8f", + "packages/daemon/src/features/media/guarded-native-transport.ts": "032f7c22cf163636fa032282813cc644d370801abcd38973bf8ba7b311fb069d", "packages/daemon/src/features/media/host-operation.ts": "af6083fddfed18bf8afccd9acfbfb0a5534cc26a04e1d568a8b977d1f47b00ca", "packages/daemon/src/features/media/host-receipt-producer.ts": "482396f8cd0c6579ed4b077e7a833a69c0382e496e1cf9d13849fba4da1f0585", "packages/daemon/src/features/media/image-inspection.ts": "d2f337a99fe305d78b77cd250eff38037e59ca6aab5c619aa81d1b9139bdd8e6", "packages/daemon/src/features/media/local-execution-binding.ts": "f861ff88f4b4b03324202a25476d92a0f1008c72d20be8fc039a41c061954313", "packages/daemon/src/features/media/media-cost-approval.ts": "739056a585cad4995f96a273771625d7e2ee9ddc0e005f6d108ecdf49ab41d93", - "packages/daemon/src/features/media/native-http-transport.ts": "60b51a3c4b19e06f48b45719e1a0514c4f89902fa09916abefc7e6b95403761a", + "packages/daemon/src/features/media/native-http-transport.ts": "ec0b59fbd66795537c04a612b3fc58bdf20cd9b5260db475a314d5e168cd1e04", "packages/daemon/src/features/media/native-receipt-observations.ts": "70f5343d00b7989d1cfa4b502500ff69582ab20f9b2c2fbb7fb37700a1edc94b", "packages/daemon/src/features/media/production-routes.ts": "16f22a5a487478b057a4c2c57561b7835855a625f2ddc4b8af7cb5fa7eecc9e2", "packages/daemon/src/features/media/project-asset-routes.ts": "af80d5cfb8425c349204674049d1ea9ba2591b3eb297142a62402c1faeef33f4", - "packages/daemon/src/features/media/provider-executor.ts": "50059a0c9650977535fd0c2ecc877a4c2ecb8ea90d291fcb244288e73695295a", + "packages/daemon/src/features/media/provider-executor.ts": "03d95c5ac60e0311a56014697752dc7d0d4e24326595c4ac6f74ac3cfd67ed5e", "packages/daemon/src/features/media/provider.ts": "551d72086fa5ccf479a529477c6c91f31c280687034fe200852ff9e9c95adb57", "packages/daemon/src/features/media/receipt-execution.ts": "ca35cf10c87eb568238b4be207be3979fef330c490783274624bb5c8a2d36fda", "packages/daemon/src/features/media/routes.ts": "a5a59516b56f9d468a8463efc1302cf12f2bcb85b82ef412bd73a8ac759dbaba", @@ -896,13 +896,13 @@ "packages/daemon/src/features/media/workflows/comfyui-terminal-observation.ts": "64e72755c8106cbd330680720c946fe80c14e2b850857c40c31f96b0c51a43fd", "packages/daemon/src/features/media/workflows/workflow-adapter.ts": "c5e5b24a33fa1718dbd67612b8c2b57709569400474ae8ec36ce4266e92b836c", "packages/daemon/src/features/media/workflows/workflow-connection-check.ts": "b1b9628001c7b2db9c43bbcfdc312a0f08b0b3a89020c957f8e1ca0ea5ba8b81", - "packages/daemon/src/features/media/workflows/workflow-executor.ts": "97990b9402f1552ed5ecd27336ccdb0fd97c855ca8eb7155d62ef7402297cf22", + "packages/daemon/src/features/media/workflows/workflow-executor.ts": "8970ebc482027ffedb7cfd21247a5e13304907784a5a2925869d1c6451a8a94b", "packages/daemon/src/features/media/workflows/workflow-request-parsers.ts": "6b8a5448f4176f825b1a5090d5ae33069a3d66dc6be30e0460868791a4803cb6", "packages/daemon/src/features/media/workflows/workflow-routes.ts": "82211721647f5856b6774421357a71ad7ed6af8a57f331007fa2657450c37bfa", "packages/daemon/src/features/media/workflows/workflow-service.ts": "ac3c5d4c5f878664591fd534ec0a48500e60a3eac056206a0444f2e91c52dd08", "packages/daemon/src/features/memory/consolidation.ts": "b66bd4360ad703fb91d948f5eec5130b89720979f9b191ac685e4d629f94c1fe", - "packages/daemon/src/features/memory/prompt.ts": "94bffc557c7a7889a4551c0bc65fae4fe8f8b44f43da2d75e3a2ff6321133b3e", - "packages/daemon/src/features/memory/routes.ts": "fb654836df3f8bcea666e3139b9b01e7101f0fd385b5541c6265db80a94eb714", + "packages/daemon/src/features/memory/prompt.ts": "014f27a3b2e82908ad7c500beccb56820210132692e8f90f9d61daf2bb2b976d", + "packages/daemon/src/features/memory/routes.ts": "e75836b865d0154549449d7f8ff6be8ae2e21374642320548b67146e194ac96b", "packages/daemon/src/features/memory/schemas.ts": "8518321adac003efcb6c25faa72b913dcf74dc8a8570d6ca367d2790b68c676b", "packages/daemon/src/features/office-documents/artifacts.ts": "720d02ac9b948f2618e1219925ecff4fda4755839738700f1db93936710abe7f", "packages/daemon/src/features/office-documents/driver-controller.ts": "3f0e35d8224944ba02b302189b41298c920bf46cd421644b67538d65b3f4681c", @@ -913,7 +913,7 @@ "packages/daemon/src/features/office-documents/office-local-authority-state.ts": "0acb7e66933c9a2227e9517b09589bee2990cc69abe7508d5d6929e862734f13", "packages/daemon/src/features/office-documents/office-local-authority.ts": "fdfe49936e4d5a28744dcbe2c2d6e6de64891a8ca901ca470062655087312bab", "packages/daemon/src/features/office-documents/office-local-clock.ts": "5fbbbdbf84143e19d4842a2173066bd69ef664630aa6c35f3ae2b98f4c767879", - "packages/daemon/src/features/office-documents/office-local-directory-core-authority.ts": "0acd5afa9c558722f9313aa05e00db153eee8ff8df65617d1b00dfa3eda538b1", + "packages/daemon/src/features/office-documents/office-local-directory-core-authority.ts": "feb07111bc0905c1bd7ca9f4e675fb48c8055110d9639445d9da41378765073e", "packages/daemon/src/features/office-documents/office-local-directory-graph.ts": "75cc04de4ca37e85500ecbfd0bf045fec3d10cf34ccba9cb94f680df5b7ab857", "packages/daemon/src/features/office-documents/office-local-directory-resource-facts.ts": "49e77e32814fd5f2c6879f78a5f480d5c67e35b8a6c60f69f2ac4aac96a7131d", "packages/daemon/src/features/office-documents/office-local-directory-terminal.ts": "b3aea85932076c82f9085091cb5dcf93b98dcd3e86d233e4a80fc8d764f8945f", @@ -936,8 +936,8 @@ "packages/daemon/src/features/plugins/cua-driver-activation.ts": "4daea622b80d593377e479fe1dc17a4336428c21605a0fd2d429d720f78d8850", "packages/daemon/src/features/plugins/cua-driver-bundled-activation.ts": "65756a8c1e2003b2340b5135902413552f5ea3acaa764842bdc9af3476db106d", "packages/daemon/src/features/plugins/cua-driver-catalog-projection.ts": "f849d9aa685f07353ef1a3fa33b0d570f1596bf65ec73582e5cb550b72dcb5bf", - "packages/daemon/src/features/plugins/cua-driver-client-pool.ts": "a5380b8cbc44af3d5365ff908642d21da8a62cb68cae623de03dd908f35dfd2f", - "packages/daemon/src/features/plugins/cua-driver-daemon-composition.ts": "f062a9810949076aff8b8aa235369124dff9ae1a184c832ae1f1623922c1be34", + "packages/daemon/src/features/plugins/cua-driver-client-pool.ts": "20d78d8e9d958707273894714b4833efb0c680f29ca676f5b9af676e196a7ddc", + "packages/daemon/src/features/plugins/cua-driver-daemon-composition.ts": "33b2b687cabad7b1b69a44a30bdd04f97bbd58f039e9e3ddfa956f8a027329c9", "packages/daemon/src/features/plugins/cua-driver-feature-gates.ts": "ef39e073f08295aa2f258d4192a92f5eb959c2bc80991bf1eda5b0e9fe23b9f5", "packages/daemon/src/features/plugins/cua-driver-generation-broker.ts": "6604e3e65c1efcbfb616f45c2f59725d374a3449068f8a50ecafcb38f30a16b9", "packages/daemon/src/features/plugins/cua-driver-lease-client.ts": "948e8d0514d47f2543faab84a56111e8e7451e52a457af475cb8ea8e2a0b74e5", @@ -957,7 +957,7 @@ "packages/daemon/src/features/plugins/runtime-injection.ts": "ea799f42f41c875345c9eb876b4f410b016e19df70bbc892cde00fbf9360e83f", "packages/daemon/src/features/plugins/schemas.ts": "3d8b11ad4e8004896a1602d30a19a7973efcd8620430488bd4fbd2255d7b5cb4", "packages/daemon/src/features/plugins/source-index.ts": "1c0c7ea632a314b1a583355f175a25437ff1f2828b75b162c03d6052fa3b48dd", - "packages/daemon/src/features/previews/content-preview.ts": "a010cd58044fa4de2ce5a6048dd32faee1e00fc04235dd19e4ad1b8386906872", + "packages/daemon/src/features/previews/content-preview.ts": "db50c6f5321a0cbaf6ee1bbb20655c252e841e3f9bb0d32831609374422b0a2a", "packages/daemon/src/features/previews/workspace-changes.ts": "911aa998f89e9bb0dc92614a7cc731d724b9a101e717ee1aa72838bb9addf314", "packages/daemon/src/features/previews/workspace-file-actions.ts": "99f69ae801152ffd4fa08415c5370d9de723a77d6685cbf6de31ab825a26acd6", "packages/daemon/src/features/previews/workspace-file-capabilities.ts": "04d23cf002041cb9c5b2d231e3d2d146c9ab3860f7377ee13da4c52924308a4c", @@ -977,14 +977,14 @@ "packages/daemon/src/features/providers/provider-reference-guard.ts": "a5ada2d551940d26efd6ccfba2575acc64d9de294fd052df8103cd91ae0a72be", "packages/daemon/src/features/providers/reference-inspector.ts": "f2a7e576767f3a9a8efc15be1dd675a74e758db046fe608852a58492b83a6c59", "packages/daemon/src/features/providers/response-text.ts": "99c4d46070e2f3e5797e6ea2d075c96265495e4a1daaf0eabdd4be4893226708", - "packages/daemon/src/features/providers/routes.ts": "6d872bcab9ca4c9a34339d55357df7da009d832cdefa12e494d78b2277f872e2", + "packages/daemon/src/features/providers/routes.ts": "54a0095ec4954277059901e961f8c4986bb5be3eef866bc4a7d605037e25f405", "packages/daemon/src/features/providers/runtime-failure-observations.ts": "612ce3aa61fe6facdd691cf0102184a34ba8795fe303583aec5f0b2eda8b14fd", "packages/daemon/src/features/providers/runtime-invocations.ts": "be0a192e1200105ebda44c853fb913b6bba1f8e222e18973b43ae562bcef16fb", "packages/daemon/src/features/providers/runtime-observation-scope.ts": "28e28f17db90c770062d94c26e64806fede9c7485b0b959bb1ee82d13ebfb36c", - "packages/daemon/src/features/providers/schemas.ts": "18b0867c2fef41d0196bc84a0c3a91abcd1cd3add2274eb96b63da285513b4f5", + "packages/daemon/src/features/providers/schemas.ts": "c7c99fbe32aa44f41e09f659a9e8e840e86b5de67f41ecee7429af0981888b7e", "packages/daemon/src/features/providers/secret-materializer.ts": "d4ab0badb97bfb4780937c6cd1535d3b7a72d9c0788a881dbfa8a31944b76f1d", "packages/daemon/src/features/providers/secrets.ts": "bdb79a9b8a7d89a5e7fecb7908c4c22a244dd669b4785fd744c0c1f37c339d65", - "packages/daemon/src/features/providers/startup-refresh.ts": "571f63e31d99a472d5ad2d60bb073909e2c1f38499a4aa542fd6d5263db4ddf8", + "packages/daemon/src/features/providers/startup-refresh.ts": "5f9c552b206ed52e5fc4d6680e368b421acf14552f5bd542ac45a58e7c4a0f0a", "packages/daemon/src/features/remote-access/controller.ts": "95877e2499f0ff82dec864f5b2b0dc306fd76a53dbcb2899dcd0c3bcba13feab", "packages/daemon/src/features/remote-access/conversation-adapters.ts": "0cd2df65b3834973cdaf022e03b40f2b36dd56470319c9008c854faeb2f347bf", "packages/daemon/src/features/remote-access/conversation-facade.ts": "842ee7bedeac3c6d1180a3beee1bf1c2d1b632643adc60e170ace0b0bac4b536", @@ -1003,21 +1003,21 @@ "packages/daemon/src/features/runs/approval-auto-review-executor.ts": "0de9e3c07f9d6b94a36b609eb3bbb64f01da054baf216c97581a4bca766c9ac6", "packages/daemon/src/features/runs/approval-auto-review-host.ts": "202410778575d863cb4a92552fc5ad9a91ef49be901c97bd3cdeeb2fe0eaa529", "packages/daemon/src/features/runs/approval-auto-review-parser.ts": "1309c5f216a47482381d9c9ff51756f30eb01c178eb1edff445536fb54975fa7", - "packages/daemon/src/features/runs/approval-auto-review-recovery.ts": "8b575270f62885391652f564cc78340ae3a2ba426ad507f822a810efdd2e0717", - "packages/daemon/src/features/runs/approval-command-rules.ts": "ae52e3746523b22c79365a845ed2d736bd3340291ad7e252121093fbe14dd70b", + "packages/daemon/src/features/runs/approval-auto-review-recovery.ts": "30ffabed8d67f2352669d8d6d25784e0ebffa13a402606b73cd357c977176f1c", + "packages/daemon/src/features/runs/approval-command-rules.ts": "dca58f9aeae7996c880a49fb561302800f0c3a9b5755418dcc73d75056f50081", "packages/daemon/src/features/runs/approval-continuation-tool.ts": "e4d169b98add387c8c23378cd9365c00d0ebe3e628f8016472ea5dc3c9076c9c", "packages/daemon/src/features/runs/approval-event-state.ts": "78a52e7da2adf5c3f8b5b227cb58e46800c9d9423b9467c8a7381b2c64698436", "packages/daemon/src/features/runs/approval-resume-preparation.ts": "b76c8024eb38f21f26ab77cb1b7445b0d36f1db41b59c50355849d947a28137a", - "packages/daemon/src/features/runs/approval-postcommit-effects.ts": "2d70217f0f126ae29fdf0a1ea5a643162b3622a83a68ba588f8fa7faf5b4ffff", - "packages/daemon/src/features/runs/approval-postcommit-recovery.ts": "8057dbe7eabd1a9810c1167c77887b2185eab873f8fdd95baf9dde0fcf8012cf", + "packages/daemon/src/features/runs/approval-postcommit-effects.ts": "cd8344d36d4d51520563ac71174c3e4651255976ff7424f4b32d79ea9422d5d8", + "packages/daemon/src/features/runs/approval-postcommit-recovery.ts": "164ec47b141d9acabbb2f3fc8a8462dfdbaf3c3e19f8a327612d3386c1e9ad84", "packages/daemon/src/features/runs/approval-preview-failure.ts": "9c66c47c73dcd2b91bb17bac893930c88b66adbe24f9f711fe4a0f4fc0afad0b", - "packages/daemon/src/features/runs/approval-resume-coordinator.ts": "6c6c3f7f7ce75d2385ba54f0a52454910397dde0db473ef56c954416f7f98a5f", - "packages/daemon/src/features/runs/approvals.ts": "20b2eac39a2c99dd7f8d7ba1e71fe72001ee2f5d4ef2803512de01ee3b1ecc6b", + "packages/daemon/src/features/runs/approval-resume-coordinator.ts": "b388e8b1c310cdb0ee18abf069c9dc5e254686407ac6818afa5518d2f47ddd91", + "packages/daemon/src/features/runs/approvals.ts": "a0624643d482c168c4276819aa1d4cebe820bfed94607de92bdf9b0291b26322", "packages/daemon/src/features/runs/artifacts.ts": "2720cd3202093dc8f67ce52e621cd7e0778ff6dff8d3273ce2ed25a30db5c0fc", - "packages/daemon/src/features/runs/cancellation.ts": "c20ba6f640a77652a3fc7bea7765bf6d048f111ea57d7b302f45309f0a50a78e", - "packages/daemon/src/features/runs/command-launch-approval.ts": "31acb4f66aa282c41dd0ad5408b8ae60008be50463e022962cb0387670be4533", + "packages/daemon/src/features/runs/cancellation.ts": "cf6b0b7d6fe9958e75cb20a43cf869a8e5d5d224eb802aa403521c4b74816089", + "packages/daemon/src/features/runs/command-launch-approval.ts": "7c9d24fad606daeeedcb5abff82acf83d467a26b1dbb9008280404e84dac2ee4", "packages/daemon/src/features/runs/command-write-risk-gate.ts": "fde17f006c86f3768ff9d738b1a53a60519dd481a4f4c73ee46ecab45cf244c2", - "packages/daemon/src/features/runs/concurrency.ts": "93ffa86fc5a0e22fec68c4d9a05648eb0d519c2c4d0ead1dc7d02b4f98764abd", + "packages/daemon/src/features/runs/concurrency.ts": "da87c82eace95e88abc9bc0871c4945d5dcf0b7ad9e72dbddf68abb397f0cb06", "packages/daemon/src/features/runs/detached.ts": "7612a21d45c59dfa9bda17da76eccee9f090e7ae0be5fadcefd7ea2da7413288", "packages/daemon/src/features/runs/edit-diff-preview.ts": "19c7d8af5b12f477a7a7fdcf784b304d8f626fec4fbeba90f3939f7250d11e00", "packages/daemon/src/features/runs/execution-action-command-evidence.ts": "cdee21457c008f9b413936e7dfb7d4c05a47840ce99fd0a8167762f321f7536e", @@ -1036,25 +1036,25 @@ "packages/daemon/src/features/runs/host-capability-bridge-routes.ts": "fd9f55722588bc36a254a351edd59f2543bb5cd66f01e4d4b2c183b8e683f73e", "packages/daemon/src/features/runs/host-open-evidence.ts": "e91196422a23a27d2c81e54f2404f10c6ce7454fa25fab57f17bf73c921beaa0", "packages/daemon/src/features/runs/host-open-local-target.ts": "6e14e3ee27b92955e860913f403a9ce659b20794a3ada7b3750a8a4d2f96d869", - "packages/daemon/src/features/runs/interventions.ts": "88f0ebedd74777f2cf1bcbb501552439af66c7d82251594659552354cfba4df1", + "packages/daemon/src/features/runs/interventions.ts": "076d56fa7f033e39049ea1f726cf30bd680280e1a01916c6493d4fd5e94ac6c3", "packages/daemon/src/features/runs/mcp.ts": "04fe519d36d1a0cbed9551271a0007d51cf4536f5487d8159af9b9f3a6ebf283", - "packages/daemon/src/features/runs/orchestrator.ts": "d2e834d484ff288b55bdc247ffde7503c63e1c1589a91fb70462c04efc9c0c8f", + "packages/daemon/src/features/runs/orchestrator.ts": "c85a5f18e1cd6e3f49e7188745c5033c3c5156156fe3ee10aac1b63e4b2f0671", "packages/daemon/src/features/runs/patch-actions.ts": "9babce02a57b5615be6619ec5fd492769d226c66ff05414e53e643c9e9707344", - "packages/daemon/src/features/runs/permission-requests.ts": "b4e62b0b0e2b7799c0308602405390d727817c0e571d593cf5137ee85d00b220", + "packages/daemon/src/features/runs/permission-requests.ts": "7b52c3f0805ebecca2ecc62b3ac1af2a570c7aa664ba9641fede27bd6ad0e62d", "packages/daemon/src/features/runs/plan-mode-policy.ts": "2a88e217fae960da088fa9fed9015b4ac4e00cdabb16bf045ded23bffa5abec3", - "packages/daemon/src/features/runs/prepared-runner-input.ts": "3c3a21cf02da97ebefc5dd7f77bbf4eb2a14b7de001e518da72cdd5a4dcbb88a", + "packages/daemon/src/features/runs/prepared-runner-input.ts": "60f96b0719c72730eee527e49198c676013ae3b28aee74c6c07c82a3e99ff68a", "packages/daemon/src/features/runs/proposed-plan-source-state.ts": "ec3e1354cce6931c2e5b40182ed1154f039e134342cd31b6f859089c1806f8ca", "packages/daemon/src/features/runs/provider-persistence.ts": "ba5238f98f7bb6daf69f3daf2b026522958ade99ed88a146e01ab98e186448f3", "packages/daemon/src/features/runs/providers.ts": "a1f9a6722b7673a66bbe9fb8689dcd883614af71a8cdb8eb88622206dc5e52d2", "packages/daemon/src/features/runs/public-artifacts.ts": "7a99b7eeaec8ffe84cc4f52d16c57812b876c6aec1105f7f3005cdf8024bde16", "packages/daemon/src/features/runs/queue.ts": "7c030cf09493539b29fb2fd8bfa64d08181c1ca55f7fafe7786e6bced4e0d3c9", - "packages/daemon/src/features/runs/restricted-command-dispatch.ts": "0e96b8c9b6998baa3ce1e2fc07e652118dee497fff20e92bf5c82426f5e15988", + "packages/daemon/src/features/runs/restricted-command-dispatch.ts": "4a42536fb27dfc826a580f19e8de594da14e549ae2c19275619e32f7d878dd34", "packages/daemon/src/features/runs/risk-review-approval-adapter.ts": "f111cf50ee399c6bd17e76ec65d59204d3a9550b7f99628d967cfe1baba999c5", - "packages/daemon/src/features/runs/routes.ts": "7d7b5851a20b8436936ffd244ee85a8da168d09e75b2ebd23cb1bc4dce664831", + "packages/daemon/src/features/runs/routes.ts": "38c241ca54434d96281a1b5f7b2b140046a01cfe2f7fa5615f2691d9cfcb31ff", "packages/daemon/src/features/runs/run-approval-profile.ts": "bf0a3801e1d6a8b21022e45ffe4b7f5840104922384692d07f1fd929b4f5d560", "packages/daemon/src/features/runs/run-callback-ownership.ts": "a4de608fb25c361f0bb0cd1266c2ff46276054b6141a21fe79e5d6c779e60eda", - "packages/daemon/src/features/runs/run-intervention-continuation-request.ts": "00abbde4896c84b9ecd04636e3d32c809b8525bbdc0c7ed84333ed470eeaae8e", - "packages/daemon/src/features/runs/schemas.ts": "f8cb1f868a9b874f31d32aff3b4415a923d678492d0f3a004775c4ab7f7e5439", + "packages/daemon/src/features/runs/run-intervention-continuation-request.ts": "59ad5e9b3d1ea25ac92f1a104dbf14bfaa2f62c399b96d3b57ff7e0cf0085181", + "packages/daemon/src/features/runs/schemas.ts": "fe78ed74a8d369b033b97dfa765d9572c35736a9bb34e0aae7a6fa474dccde18", "packages/daemon/src/features/runs/shell-snapshots.ts": "cc4cb16b051c5c83f825067b329caf64fa076a9232582297bece9e0a475900f4", "packages/daemon/src/features/runs/skill-selection.ts": "d9c8483126c3e66759fb70c97dd6d9d1ac33b76206ec67b1214a69395005cf4f", "packages/daemon/src/features/runs/skill-source-context.ts": "35e251b58b1801d2b7429fa1440887007926585d68ae6e72365c9b6cbfb6a789", @@ -1062,28 +1062,28 @@ "packages/daemon/src/features/runs/stale-completion.ts": "e2322f6a207b87f50ec6885292ee97c2074e84e29db9befbd889747f3e3c7385", "packages/daemon/src/features/runs/terminal.ts": "612f0ffe7b21beab16e474c968fd78970a9c3fa731a8247443af451fcdce3da1", "packages/daemon/src/features/runs/thread-hard-delete-route.ts": "b4c77ea870da2c92536f8a6f41eb2ae64ae87120b60df9fc9f0af58a03e1e3ca", - "packages/daemon/src/features/runs/thread-routes.ts": "43420d3b32ac2071a195928c8fe1747bd53a200f6e0762cb4c75d7bf368bc585", - "packages/daemon/src/features/runs/threads.ts": "1a18874df246e82dd5a65721b213f241e7e4cfc0585a4c72c6dae89ba11008f4", + "packages/daemon/src/features/runs/thread-routes.ts": "4652ac844e5d7cfbb5cca7a59581c419862d110004124fa00b3d93112cf6e8d1", + "packages/daemon/src/features/runs/threads.ts": "34e45d03fccefd5301d626061df114b7856867816ae29026859f92091fe15eca", "packages/daemon/src/features/runs/tool-context-budget.ts": "e326899511b2b67c71420b2d78089fb397e24e270a11e636eb5197722dd0bfd2", "packages/daemon/src/features/runs/tool-surfaces.ts": "75ce74875c8312cc7bc36889000af2c26509ea9343ead751f4c3264a43099951", - "packages/daemon/src/features/runs/traces-routes.ts": "a884feaba263d0d8ea1caf3bd1d9c19bf0dd5e758b8ce02f6e4fd0c23c5b5863", + "packages/daemon/src/features/runs/traces-routes.ts": "3783462229d664d971d60984ba20771f2ed1b197b936d0eacbde6df03feac5ad", "packages/daemon/src/features/runs/unified-exec-model-output.ts": "03edd2b23797f9dd18bb4031e0a6d32eca6484c476d5acab34429d5fc2185bc1", "packages/daemon/src/features/runs/unified-exec-output.ts": "984cac7a71c86ad28feb41352e27dbae0391f001cfd82cd06b748dbec1a0d4a2", "packages/daemon/src/features/runs/unified-exec-process.ts": "ef60d23405522098d8ddecb5b06f9872f26bee06721810f7fde8362f8f4cf0eb", - "packages/daemon/src/features/runs/unified-exec-raw-source.ts": "45455bcc6772bd5d0c2f53ab20b950d36f4aab2e31c7f089b607c452b8097e3b", - "packages/daemon/src/features/runs/unified-exec-request.ts": "272c266aa464fb2325c8e741025ef69090f7c75220f5768d9751fe69262fcc0d", - "packages/daemon/src/features/runs/unified-exec.ts": "be9519aa5f8e97d8f4a5a602f91ccdc58edfb3227184c3e628efd9139b439422", + "packages/daemon/src/features/runs/unified-exec-raw-source.ts": "3787468c8fa169f1706d516996938355b83624f7b08713b06d21ae5d0e589716", + "packages/daemon/src/features/runs/unified-exec-request.ts": "1f440ac8be7c866dbcfd76e794337f0d1495a32445953063bcb29e930f861f6e", + "packages/daemon/src/features/runs/unified-exec.ts": "9c668664c6afbfeccfa25211110922db93af47706ca471b0a7d1182d45a6e160", "packages/daemon/src/features/runs/user-input-cancellation-guard.ts": "0b5e8d707a2817d45d89d2f3d2c8b423097347b64f888a82a1b3237b6a5352dc", - "packages/daemon/src/features/runs/user-input-local-writer-scope.ts": "0ae272856604d579b94f659f2676cbb9fd4f3a710219cc2fff7b59286dd08e9c", + "packages/daemon/src/features/runs/user-input-local-writer-scope.ts": "1e932239d7dbd249a38213103a13b631fd0835738c587a12ffae3e8e0dd66607", "packages/daemon/src/features/runs/user-input-pause-coordinator.ts": "6009d08e9ae55b1cc623ede32e38454247d013214eb545236f249d00bb2c441d", "packages/daemon/src/features/runs/work-units.ts": "f0503f6d531ea2c7ad93d45ee72ffcbe6c51938612dcc8bcd04be0ae180f5690", - "packages/daemon/src/features/runs/workflow-operation-effect-gate.ts": "952c3e58fad7ca3854cbf1f80ebe73034734f82eb8715fbd42ba9ab481de1f17", + "packages/daemon/src/features/runs/workflow-operation-effect-gate.ts": "89c06893ba8652e66be0b739ff09f3bfc5e76f18735a298a1b1fcc6855cf11ef", "packages/daemon/src/features/runs/workflow-operation-snapshot.ts": "1e0cdc18cdb624e495fe70f441b4f1613fe6dcdfda18796111fa8f3b8288bc82", "packages/daemon/src/features/runs/workflow-step-start-admission.ts": "4c23da41803e4ccd55484d4a019a6108567e874d2bcb4c3cae3ee68db20a4195", "packages/daemon/src/features/runs/workspace-change-recovery.ts": "bf4546ab1b96ad708bbe0620a4768ad5a95376d766643d80a72e02ffa1527256", - "packages/daemon/src/features/runs/workspace-change-review.ts": "35d2eb867a9bcda10b779566f6cf3d3e9030fd3e9beef003fbf51dbee0703f55", + "packages/daemon/src/features/runs/workspace-change-review.ts": "6af5e18340c06ad8aa24b5874c0137367f1d5589a4cf106621dd497aaf387d36", "packages/daemon/src/features/runs/workspace-review-snapshot.ts": "0a634e361aaa6a59d7a3d8c5bef4dc25f690927d6492d311a337ff1c92d315ec", - "packages/daemon/src/features/runs/workspace-snapshot-restore.ts": "5dc186c60349b2d46d7aedba5561366daef2a47695403f268bb18417384af5f3", + "packages/daemon/src/features/runs/workspace-snapshot-restore.ts": "f0db9b03d806c620f55c816a6bd96414d0199f878fd5f1c942dfc38e2b2e7499", "packages/daemon/src/features/runs/workspace-snapshot-routes.ts": "540b5e2eeb2fb7e8470f2211e8146a1b37741180e985c96447b40084f048400b", "packages/daemon/src/features/settings/effective-config.ts": "40c0d8ed376bfc208ebad0cb7c44b9fb15e32cbafee741d048b04cfbcf121cc3", "packages/daemon/src/features/settings/routes.ts": "f9247cee8fd3505c2cb5e4f81f7c9ef8078f93949ee7f1c2eb04b5140d5d7cbe", @@ -1096,37 +1096,37 @@ "packages/daemon/src/features/skills/packaged-runtime.ts": "4a9b3daa288a6d9dd490932ca59766202db66b0bec36e6d12644e84e5a3f584f", "packages/daemon/src/features/skills/packaged-history.ts": "448687cc27f8fa5556bd8737805141fa1dfda62e4d9ec27e17016047fe73fede", "packages/daemon/src/features/skills/bundled-source-mode.ts": "d4b90028ba60d0eb7e439505c9464a3810ee7ecd93941cb1f7515fff23c1737b", - "packages/daemon/src/features/skills/revision-routes.ts": "c5db145f16b6e49cd6f9b0d21b5eed39ee31bf3b1fcad995a8bbb3ebbbaa67ae", - "packages/daemon/src/features/skills/routes.ts": "f8a3e8ceda55f0543218ad9786b31a3f79a1af58db19648f265f8f822b832a03", - "packages/daemon/src/features/skills/schemas.ts": "849049dc13c1cc3f74ac1bc6cbdacab9d6b01205f050d509a1aa98e985500949", + "packages/daemon/src/features/skills/revision-routes.ts": "82cd146c81f9fed02621fa901ae3a4ae6a3bef4038d4ecdb6307e7124cd4294a", + "packages/daemon/src/features/skills/routes.ts": "5abf7fd97fc2bb52687b03cf29bcd5f7eca823c04a6ea4146d4161f653100387", + "packages/daemon/src/features/skills/schemas.ts": "321b6d93130b303fcc49f79ba6c8f29e281bf8beffb5cbe34b3899fe2baacce2", "packages/daemon/src/features/skills/source-publication.ts": "1e0887b101641b27eb5a4dbbfeb9bb8b07756984256e27f3ba13ef71298559b5", "packages/daemon/src/features/tasks/routes.ts": "519ee5d3caa3cbcb7b506bea4869e70cf0e2214989c84137827f75b3d4d1f980", "packages/daemon/src/features/tasks/schemas.ts": "f6aa9527d4e2628a81aaf5409f3a0acc9af9918097eae854fffcaae9c5953830", "packages/daemon/src/features/tasks/task-attachments.ts": "e5af3c02f0fff3490b9620db228142b5f3a013c97593652926b795e6ffd5eba7", "packages/daemon/src/features/teammates/activity.ts": "e5396778b045678358b3975d84b6fc9905ab19e63f27459283f6ff13a62b88af", - "packages/daemon/src/features/teammates/direct-controls.ts": "5814fdb1428bc6da999b7e7792c1906368442284ff405821b7700f1284f8e7ae", + "packages/daemon/src/features/teammates/direct-controls.ts": "4fb1ba3b596155b8293d375f56c844293fe6e1b9481d8ab9da37d202c0ac340a", "packages/daemon/src/features/teammates/direct-execution-context.ts": "322293fd7a3dd81376a47a7dd69f35ec06b18fc2845355f956de595c16f29df4", - "packages/daemon/src/features/teammates/direct-execution-ingest.ts": "d13c9ed7295b3b500c60345ad0cabf2b89e92fbd7879e15310491ebc7184853c", + "packages/daemon/src/features/teammates/direct-execution-ingest.ts": "17c98a61728f244ae79fbd9ae5859018ba0dccc84bf3422ab14349c53556497a", "packages/daemon/src/features/teammates/direct-execution-projection.ts": "a13f1d6a1bc58a3b9e8362c98c9da56f9f87962bcbdc5adbe671a6ae45b1e789", "packages/daemon/src/features/teammates/direct-input-snapshot-projection.ts": "84cfd3ab2d9cb85ef3d8165ce5c04acb3d1097a045bff1185632ceb91dcb6cc7", "packages/daemon/src/features/teammates/direct-link-previews.ts": "4bdc1f499e52cafb737fa05744b4465e45a3995aec98c0b177e2d26135e5cc15", "packages/daemon/src/features/teammates/direct-materialization.ts": "71d83387087eee3db38afe999425c343a1a6406aa2564804fa2d4a163e3aec1f", "packages/daemon/src/features/teammates/direct-message-narration.ts": "2813257063934dd4d9a867690397169017e1906a0f383bd3aaadcd840348423c", "packages/daemon/src/features/teammates/direct-message-lifecycle.ts": "349101df1a51566a49cfd9057fbc442ed0505257a44ea8c4b9ef7d22c11c7c4c", - "packages/daemon/src/features/teammates/direct-messages.ts": "503a952c05db3e94ae768138ab3d6ac6fb99f2a1de90f423187f4adebfdb158c", - "packages/daemon/src/features/teammates/direct-replies.ts": "984c74151c512e5dd2eec55a456152b5fa282e298556a340c0d4974dce8f8e81", + "packages/daemon/src/features/teammates/direct-messages.ts": "f5e03d35f8600ea3e76ba0cc1369a89191db8467b172b6fe5a57090063b4a14e", + "packages/daemon/src/features/teammates/direct-replies.ts": "c7ae8190e0f2e3153d64184be7e38c0977ad6d3fa83530158f727fc01d4c7b62", "packages/daemon/src/features/teammates/direct-reply-active-registry.ts": "887d8fe76b6a35e91aee63727185f321a3241486f4bddd607d34895d35ba47d8", "packages/daemon/src/features/teammates/direct-run-owner.ts": "ea73d5d0b620c738317618afae3f20baf2ced907f4964c264bfff5d89b8e80d8", "packages/daemon/src/features/teammates/direct-submissions.ts": "b632258acd6dd0dfcec4cf3a0f63dbaafde255898e8dfa23e7eaeef0c337f53f", "packages/daemon/src/features/teammates/direct-transcript.ts": "d18b7593d088d474682684c02f30d04835df8f070e50a134719c3d180b7bacba", - "packages/daemon/src/features/teammates/direct-workspace.ts": "a51a987a34363adcaabca1cfad08f9cc07280223f210b7fa41aa85d2156d6898", + "packages/daemon/src/features/teammates/direct-workspace.ts": "ff037d45136ed7a10b2e85db2fea44f249ad40711fc6027983e5f1b5cf47f378", "packages/daemon/src/features/teammates/identity.ts": "1594b14cb503434ad3095f9432604b110a2bd7b051e4532675b4d5e4daaeb923", "packages/daemon/src/features/teammates/model-binding-validation.ts": "3dd44dc94c319df1ef5d1025afdacc971d1be7d68bc63db738549bbd7d4eb997", "packages/daemon/src/features/teammates/private-attachments.ts": "f7785d9a832b2de477e4fd8e38af65ab895dc5277055cfb54b508548c044c2b5", "packages/daemon/src/features/teammates/routes.ts": "a18019de3131c13ab24e4c6344db0efa40b8faba669a7e03de9de2fa89a53f1f", "packages/daemon/src/features/teammates/schemas.ts": "45340230290f5d6044af1e6e8f1dfd8a028340318a59f3e6ef620443c81ef680", "packages/daemon/src/features/web/message-web-evidence.ts": "7d7dea22b567ebab2217c20cc583b0e159c608bb26f612a5fc578c272a85d064", - "packages/daemon/src/features/web/web-evidence-service.ts": "6edb6aacf439401858fba02ad57a7fb4f87f072ac198610092d55e3bba3a3c08", + "packages/daemon/src/features/web/web-evidence-service.ts": "1b1bcf3edd1a9a5af522a6dcbe5b365c5b1b37d3ec2cc937f2d9951d769c2b62", "packages/daemon/src/features/web/web-search-adapters.ts": "4e4447bdc6d62f38723c60c6b2c1a387aeb9bc648063f6f08af108e4577ff6e7", "packages/daemon/src/features/web/web-search-service.ts": "8f8f10a4218a5ff7e506b3ba103c79a8d1102d11b97317298c934fc7ae931e47", "packages/daemon/src/features/work-items/routes.ts": "e427016cdf2d0403e3e6e13f175070df4575a0783e5ac8a9e1615c5c75fa8ff5", @@ -1134,15 +1134,15 @@ "packages/daemon/src/features/workspace-directory/routes.ts": "a6984ab65bb3cc547e16616299a0b8b0b349db02f0830df9b9345d471d29e646", "packages/daemon/src/features/workspace-directory/terminal-runner.ts": "974004c8062dbc0022251286ccffd5a09ff160fcaae047ec17118de7b3a7afa3", "packages/daemon/src/features/workspaces/managed-worktrees.ts": "a4ff4a81224117893a005b50052e137f2189e557e80242921f2384689fcf9c0b", - "packages/daemon/src/index.ts": "28c95ebc181888cd271e7c0c8b5323e6cfd18dbfd2a004d93a23436a77db2d27", + "packages/daemon/src/index.ts": "459e1d64473e5bd0804cfd918a880e5ccdb7fd2a843d41456420eb950ae2da83", "packages/daemon/src/run-control.ts": "423d95f4390310025a5c76a9e0e3b1456d4a26b4a299c01ca61a95664ccbd327", "packages/daemon/src/runtime/acp/capabilities.ts": "d4c3fd88837c75f055a015487f309b92905cce3114d4fe0371cb32f6288800a6", "packages/daemon/src/runtime/acp/client.ts": "b366ed5d071fa89ecd34c24d595318e6f747e3de9171b7d96d3a96a273a4c353", "packages/daemon/src/runtime/acp/protocol.ts": "643bc50b31cad1f633a48d83588a40266704779eb4ea912af4b3883628401b4a", - "packages/daemon/src/runtime/active-runs.ts": "866f994805105730afcf979f31a52bd725a952027c4e9323b6faba431c031a1b", + "packages/daemon/src/runtime/active-runs.ts": "c1475d85cbc4fa7cf0d3420675849dd83b0f610e5bae0b3f70f0d303d65e15fd", "packages/daemon/src/runtime/cancellation-fence.ts": "c0e977dad6a5cf0a704e8a34a6e577f48b4b08cc240095d03680233eb9a35e95", "packages/daemon/src/runtime/continuation-source-barrier.ts": "c3e675bad0e8822cc38a1198eb9e2cafbb1c85251f83fe5ecf0483a0ca122c5d", - "packages/daemon/src/runtime/host-attempt-completion.ts": "43d2fed68d0211f67df7f093401d7e988d015e5e97bdd6d916bcacc9396efe04", + "packages/daemon/src/runtime/host-attempt-completion.ts": "0fd31cb4e114f27eab17df931c03b4c02efddd252b7fa1a16f47dd2c17df887b", "packages/daemon/src/runtime/adapters/codex-app-server.ts": "7f72a8744f1a2c34d69f06d465694a13f3009d68a1ebbecff7f03ec6a65061d9", "packages/daemon/src/runtime/adapters/openclaw-acp.ts": "d4a8de3bf2b536ba71a9488f061d8c2b43c3afb2c8685d8aadda32cd7e236b38", "packages/daemon/src/runtime/attempt-execution-quiescence.ts": "6dfe23b57fe6c7e73e75bebbba608cfc23d76a6fce42a005e6e32320a337c247", @@ -1157,10 +1157,10 @@ "packages/daemon/src/runtime/external-agent/workspace-boundary.ts": "34789c63addbf407143f151930f5f1320927af023bc2359a2df21c88e91469d1", "packages/daemon/src/runtime/external-runners.ts": "e5446bc9fd35b5cc133d811b71ecd631753746a2f3be69209be2b7499905653f", "packages/daemon/src/runtime/locked-task-continuation.ts": "7b9353d188651514cfa8f0462c60f6fa7b830498d620a890e0ab1d8e40968650", - "packages/daemon/src/runtime/media-local-executions.ts": "b4c2574e84b7edf99d141af6b1813d4b7c5ecbc7f3f020aa4fb06b797d9d28fa", + "packages/daemon/src/runtime/media-local-executions.ts": "20898556403ec87b042ea279908ef86490c133cf6300f3d577497d2c489ed470", "packages/daemon/src/runtime/work-unit-stop-evidence.ts": "30d77887d5eecead2a92646739e1ae2aa0a2d037c2e5240446887bc37d56be27", - "packages/daemon/src/runtime/routes.ts": "d6315dcfaabb8076bc8d9dc3084d5bb64e5daf16e488d0026852faf9cc08ec36", - "packages/daemon/src/runtime/harness-diagnostics.ts": "c86fb3bc3cad841a3e82c0b646c6c50bcd59815639e8cfc4c47d8d1591fa8063", + "packages/daemon/src/runtime/routes.ts": "8e69abeadd584fe5135e54ce7e3464b15934d8e0339e8dd8a968ac0c4b579313", + "packages/daemon/src/runtime/harness-diagnostics.ts": "c4867f1d2f4546aff3bb519738a46d15e17eedb5fc8d5ed95b020f69bf508a11", "packages/daemon/src/runtime/runner-readiness.ts": "61f669e4775991ee26a56e1be3b7e056a105c81f274dda8d9a2a9666eba6c1d4", "packages/daemon/src/runtime/runner-settings.ts": "44fd8a558328032309dd6150e5e9c8ba46761fc09d6750ea69172764e635c1ef", "packages/daemon/src/runtime/runner-tool-conformance.ts": "2beb8bb3d4935d3f47860a8a839a29a6a33adb2105d199debb77d9d63bc22577", @@ -1183,23 +1183,23 @@ "packages/daemon/src/runtime/sandbox/packaged-restricted-command-bootstrap.ts": "39ae1586401dd451dd91378b17c2a4dcbff5c20efcb0fa4a6089af401a88744e", "packages/daemon/src/runtime/sandbox/platform-native.ts": "3dc78d67db35065385974ecf11adea1fb2be1f9edf80f9d409d918f5ccd3f2a0", "packages/daemon/src/runtime/sandbox/protected-host-actor-domain.ts": "acc2bf56146e40a055e5f3bd29e365c9ad45deae5f699cb825f0cdafb41f488a", - "packages/daemon/src/runtime/sandbox/protected-host-actor-inventory.ts": "b7dd5129541f65465484308439b882caf8cfc47dff676f39a1c3212050a3d8c0", + "packages/daemon/src/runtime/sandbox/protected-host-actor-inventory.ts": "e66a46b7f40344a6f44efd88cc6bd07e19047cc0ec49379b5465999d2d3fcd7c", "packages/daemon/src/runtime/sandbox/protected-host-bootstrap-domain.ts": "755285b1103723d0c5b349d8cceaa84def9532bab15da509ce8074d884059d42", "packages/daemon/src/runtime/sandbox/protected-host-domain-paths.ts": "47353ee62d0dafe3a5692b90d56fc74e328fa809ce87206b3dc1b7c9c58f7515", "packages/daemon/src/runtime/sandbox/protected-host-office-directory-window.ts": "7e0b25aa75b16b735ced7ea6d6272f52e85e97323bfdd0d50389a02ee0ef063c", "packages/daemon/src/runtime/sandbox/protected-host-office-native-identity.ts": "b88a575f8cf664f39fc75664db9277878d6e09180e67998fb215fc51db73dc0b", - "packages/daemon/src/runtime/sandbox/protected-host-original-entry-manifest.ts": "bc63175b25e6ae380839cef9e4471879244577dbf36f463fb994126ac9cfb87d", + "packages/daemon/src/runtime/sandbox/protected-host-original-entry-manifest.ts": "1b6de192416fae886c976cc1b11091e4790e13b90929dababc9bff920065b3d9", "packages/daemon/src/runtime/sandbox/protected-host-stage-paths.ts": "3f22a568a91d5424b288486b87b9f82cc31541d4b18c8bf6454f96c8b8c41835", - "packages/daemon/src/runtime/sandbox/protected-host-staging.ts": "7ed1377a459f2d2b0c17c2ec82a390465227413b49cf0d9030adb4197e173ba0", - "packages/daemon/src/runtime/sandbox/restricted-command-runtime.ts": "7886fd8625fdc2a4f690dcd607f5e4ad3016a55573e9d2995a7c8e523e709e9c", - "packages/daemon/src/runtime/store-registry.ts": "56507b7b941ea4b20395166c5ab20d857d516703073bb185f7c6a7b12955de05", - "packages/daemon/src/runtime/supervisor.ts": "8a7e2d167977c0f2e0dadbbba4902c12dbe2e0e65820875d2a051cc95003adef", + "packages/daemon/src/runtime/sandbox/protected-host-staging.ts": "1e869a2e56ce40582683bc2df6745e30e3a0a8c7e2238e97f509225702b010a6", + "packages/daemon/src/runtime/sandbox/restricted-command-runtime.ts": "0c836c569ed65f53b848d21364d5b1c5c54da629bbcf99d31828a724311fcccc", + "packages/daemon/src/runtime/store-registry.ts": "f13fdb6562db9f112b3adccd92ffad3ef71e1220d1446a593bf07dd868e7d22c", + "packages/daemon/src/runtime/supervisor.ts": "d77d006f9c59f60d212d7eeed850fff55875a23dee4400141ab78017aa1f849b", "packages/daemon/src/runtime/work-units.ts": "ba063e3a3500ed1fd75004cb8b7e2244af838800a2c6121eb3215f07b0a5f57d", - "packages/daemon/src/server/router.ts": "10ed401c973c30753a917b69435558e3596980e548de75e5567d060cdf231363", - "packages/daemon/src/server/routes.ts": "2d1823add7710f2c28c2d7f8427979a944d0c2d49bbb6165b7f1ac2f16284159", + "packages/daemon/src/server/router.ts": "e140e2dde871f769149ee3d63d9f0a1f13bf994a5f208c8a1ea416f0889c78ba", + "packages/daemon/src/server/routes.ts": "2090709f4c4c9306bcf616e5ee673fc97e9e86fa102d12de46edafc3bb26be4c", "packages/daemon/src/server/rpc-outbound.ts": "ff3f8f5c2451d51e770f0a8fcfc6098d6d18a00105f343b780e2071bd557bbc9", - "packages/daemon/src/server/rpc.ts": "2178c356c7a3a3864f266faf1ce0f2faf55b2149244ccf8f4d4d7c8fb3057396", - "packages/daemon/src/shared/approval-profile.ts": "4d67d9ee591708a812bbc67dedd13b4d813a029501a56adbf5567fec30ed4812", + "packages/daemon/src/server/rpc.ts": "0ac4e67f22177e2e8fa98f0c0a90e99e87e4ab62614d83d45489e6e7b5d3e7cf", + "packages/daemon/src/shared/approval-profile.ts": "99bea01e768f78239386c2313ab0188726ff359b9c2037c55fdb1eca39b3d6ae", "packages/daemon/src/shared/assistant-text.ts": "0857fac41fc618ff4106cb8e3debed2ca97d3be419f1f00ac4d1ed54af1c1c22", "packages/daemon/src/shared/cancellation-request.ts": "a6fdca9a241af85166cfd0b8df71c5877b4a5c38a06ca4e918acc65fd2fc02f2", "packages/daemon/src/shared/conversation-response-style.ts": "fc558f97052859220f4f0f60d9af50e62b785cfb86fdd7aa62c2ef51cd4f116c", @@ -1208,7 +1208,7 @@ "packages/daemon/src/shared/local-workspace-access.ts": "8a5db2ec37b3464de0ca56ab182eddeb6f9e6fe133565f686792c1675d4c0583", "packages/daemon/src/shared/markdown.ts": "19315deb4a1c9cc36244f71d0edfba4b87e668a92a8b9c6c3bc844e46581a819", "packages/daemon/src/shared/path.ts": "ce4484ddb3fe6d360235889cb14f0c83c214cc3d47b0392676f2e52adf02381e", - "packages/daemon/src/shared/public-http-transport.ts": "937260032927e4173202138d6b83f0893d948c60b1b76bd9e6ea8aa9c94631fd", + "packages/daemon/src/shared/public-http-transport.ts": "39c5abeb63284f950fc0c1ecc3a33eeee435689327ae78f6e111a86ce0ccc65f", "packages/daemon/src/shared/redaction.ts": "800fec11596773c3753a8f52afe5c477340f8cd75cbf614a135110451fb4a030", "packages/daemon/src/shared/request.ts": "8af7defca169d2863d4d2e3a36573eb4c7141ac49a2f173f2b88c30e0bc6a739", "packages/daemon/src/shared/response.ts": "884354d3f34cfe3893bf3828b2629f41e40f9c07a34b4a56ba50ec5b8c47b386", @@ -1226,62 +1226,62 @@ "packages/localization/src/catalog.ts": "c3b214e2618e15fabc8238019103cfa3b04f0fa998f8c8dc3c22a184bdac57c5", "packages/localization/src/formatters.ts": "b05b773eac8e37e9baf01d4059641bc1119e0a7248d75d32986af558eee4bd35", "packages/localization/src/generated/catalog-loaders.ts": "152605f3f5c022c9ddb8c443ae3f1d42979de8583bb0b99a30657222218d63f9", - "packages/localization/src/generated/catalogs/bg-BG.ts": "030caf7256d9849567fe314f01524751595ffba1489e03a322a4dc3f3173d204", - "packages/localization/src/generated/catalogs/bn-BD.ts": "e1742d90d1015e2da575bcb958e31b07a65898867907b26b19669c381808e585", - "packages/localization/src/generated/catalogs/bs-BA.ts": "8aec7ce8bcf94ca3500c4087355f2ee0a7ceb68017b0bc280838085026554e2c", - "packages/localization/src/generated/catalogs/ca-ES.ts": "77fc64a6406248623562289c326b3260d826b0a2697f4533a60cf5cb3c9a99e5", - "packages/localization/src/generated/catalogs/cs-CZ.ts": "5d4e055a95afde4a794685cddfeacc6f9f9b5b651b36a5a56ca9af1108e0d7c8", - "packages/localization/src/generated/catalogs/da-DK.ts": "096099b5ad02aa2851c2437dfb26ec635c1b60e8bc5f4c8194b1c600c0e99a60", - "packages/localization/src/generated/catalogs/de-DE.ts": "20c05d2a5a5b630df4fed980eba23ccc80fec817651ab0667e614ddc35fe78a2", - "packages/localization/src/generated/catalogs/el-GR.ts": "72531daeaf52367710c12277e346b8f4913736e432d2fe2c0f443ddb4dbd563f", - "packages/localization/src/generated/catalogs/en-US.ts": "c118d5d6decb230e6b7ecd34b6b4aec59a1868cebf0b9aa28627158f68284794", - "packages/localization/src/generated/catalogs/es-419.ts": "d929e39c8f9f4b4549a24e34ec290904672eba8570b8842a94ba856b478a7691", - "packages/localization/src/generated/catalogs/es-ES.ts": "cac4dc20e8594111c2fd4cd9e8c03fb62a64768af60340d0c5e031f3479914af", - "packages/localization/src/generated/catalogs/et-EE.ts": "756e96392b464caa126960d90cb2d31ff1ee9d3ea8284948dfd96b529fb02992", - "packages/localization/src/generated/catalogs/fi-FI.ts": "c40e64d50234781b0810e2c14f8b50c32a03555a873008172cb0b6387be1657b", - "packages/localization/src/generated/catalogs/fr-CA.ts": "048c4b3779206b568e815cd6019a78f6addf943775f29a2ed6d4ea9ba6f798d4", - "packages/localization/src/generated/catalogs/fr-FR.ts": "c2215a5afc18e47d9c06c781871bb09a847aea1cdf72ee713155ce65a4d6726e", - "packages/localization/src/generated/catalogs/gu-IN.ts": "7dbdb885a15454d5dfd79112be1a0ffa68806dfc7d142f7b0ad3573d0c7332c0", - "packages/localization/src/generated/catalogs/hi-IN.ts": "e102809349776a2943ac4d20f3a1a0b083c58dc59dfcc6045b8210ddabe11feb", - "packages/localization/src/generated/catalogs/hr-HR.ts": "f2f590cf3cbc9964b3cf0fa3e755bbb2ff9fe8d82f91270bb5cd14908a3910d1", - "packages/localization/src/generated/catalogs/hu-HU.ts": "2a7bd16cebedb0ea1c6aec2b650ba9069b963bbb9613410097c8cbf36f7e3945", - "packages/localization/src/generated/catalogs/hy-AM.ts": "7013ef64551acbe693c553ebf2d9c832e2f5ef3f89b3b98fb5a69654b2508918", - "packages/localization/src/generated/catalogs/id-ID.ts": "1848bf251f2e0b37a9a89fcde55c0e6cbb8edcd65cf2935183703c9672f1ea81", - "packages/localization/src/generated/catalogs/is-IS.ts": "b037d1b403615bdef33a7c3f96c1c904a2f26187d9017f3f072c8ed6154df822", - "packages/localization/src/generated/catalogs/it-IT.ts": "58c6118a654cc5f457b540f0cb5d2683285556a7030433099eda776d645daec7", - "packages/localization/src/generated/catalogs/ja-JP.ts": "491e03027a8148d2d069ff0b2ecfa43611efad100ffdf5d272949f1dd3a891ba", - "packages/localization/src/generated/catalogs/ka-GE.ts": "5d72393f673004a1ba8056b0e229c9adc27ee30d84935a2c6faf43c75d9c74a2", - "packages/localization/src/generated/catalogs/kn-IN.ts": "79380906e7c11fd0bdc706c5b66476cb68f822291034da00dffe183e0128c2e5", - "packages/localization/src/generated/catalogs/ko-KR.ts": "8321ce002b1de892cf09b41ec70f70fd9a3ebf99da3f922fe59188e824d40cd9", - "packages/localization/src/generated/catalogs/lv-LV.ts": "f23be06fc2e567cbc203dc5c5d3608dad2c35ef17a4d88543d287edd4f1bfb7a", - "packages/localization/src/generated/catalogs/mk-MK.ts": "8aa7be388f85c793f7019eaeb8a6989a10935ca4f7fcaec15c7b4ba4a2c9de28", - "packages/localization/src/generated/catalogs/mr-IN.ts": "4b499bf38164fc682e2c2f99627a688624c7db1e5f25aaf73a17e2d90ff8793f", - "packages/localization/src/generated/catalogs/ms-MY.ts": "ecae2610855353dca2abaef4da99f1979d2480768ca1af3662cdd1e9f903a4fe", - "packages/localization/src/generated/catalogs/my-MM.ts": "694cd524985e90c0d3495504db551e65ac1e3295bdb0f9808290f4a334f2eccb", - "packages/localization/src/generated/catalogs/nb-NO.ts": "4abed0aac6813211a8df1831fe25944d96601e945cb24eb3a44966000c336794", - "packages/localization/src/generated/catalogs/nl-NL.ts": "0de6ceafeff4be3b7063f598fe5465dfb004abdd25a6d2a4ae838f3ba6184643", - "packages/localization/src/generated/catalogs/pl-PL.ts": "288dfdf41d9d14c99a4e65b3073dd4036e426e3b132f153ce366a0945c74a143", - "packages/localization/src/generated/catalogs/pt-BR.ts": "7b318a4b5ea85836535f0ee4f0ba1c8dc0d5e0008e19e06b00e48d93f6d8d253", - "packages/localization/src/generated/catalogs/pt-PT.ts": "816ad8f3015b5c623ca1f68e382ab1d7ddd005b8d2bad05e4aaeb35a4f6ea721", - "packages/localization/src/generated/catalogs/ro-RO.ts": "96be68720d292037c86c6ece062a11cd9a09ce0bb481214031a42e5172e4f260", - "packages/localization/src/generated/catalogs/ru-RU.ts": "204dc9ede04c871ba5ebc7911cad7011ea8d0c2713d3c652981294eeba993f0f", - "packages/localization/src/generated/catalogs/sk-SK.ts": "12f5be8df6e2c24491dc39bfb435791da22cb7b8b5b106edb0feb468047e0c24", - "packages/localization/src/generated/catalogs/sl-SI.ts": "508a162d47c83a14b9e84f874d585b38d08b8b5c49cddb16f077e9eca3b42302", - "packages/localization/src/generated/catalogs/so-SO.ts": "2904f1f0a59e71a69d0b359f18047dc853cb558d88cd132de27367fe7fd07722", - "packages/localization/src/generated/catalogs/sq-AL.ts": "0a6aea10f6c608dbcc5c8c59d7d02da7df3557ccaa50da1cbacfb3fea546599e", - "packages/localization/src/generated/catalogs/sr-RS.ts": "b912b5d8379e3a6a488f61315df3b3845804d224a5313b3221fdd14df031b5cd", - "packages/localization/src/generated/catalogs/sv-SE.ts": "5dbd677fce69b1f903464120fd774f44720afbc55bcefc73a95005e72a354ff2", - "packages/localization/src/generated/catalogs/sw-TZ.ts": "d89d70dcfb7d58db50d07630bdf7975bf31ffd090e96ac36c6cadb44af9286cd", - "packages/localization/src/generated/catalogs/ta-IN.ts": "4b25e47719a59bd4ee25ae6751eb0ab38f27b9aa81ad82b7a62ab51c74323372", - "packages/localization/src/generated/catalogs/te-IN.ts": "9ab85a2e3a03c8a8b773ca36913e5f22c339911a9c09cb260fa13b99085eb7fa", - "packages/localization/src/generated/catalogs/th-TH.ts": "0b7ba6c3048af3b3106d6563e2ef28995a73f0c0eb801976aa803723ab3e750c", - "packages/localization/src/generated/catalogs/tr-TR.ts": "82f27be18a47f19e6d7938e4d914b47b4ebd9ec72264ee05f22b4d6063549565", - "packages/localization/src/generated/catalogs/uk-UA.ts": "8be1a7cbfc838537ccae83cf52df6c861bdb535cf9ed453f9b192c94c42e7a13", - "packages/localization/src/generated/catalogs/vi-VN.ts": "1c260c2e332b3718cf6a7debf3ba2c179cee76549c43f905bafcd204ab63bf6e", - "packages/localization/src/generated/catalogs/zh-CN.ts": "9a3247fcd6965a02f0e0c2eb4af216052e2997bc57967d08516e0891bd288296", - "packages/localization/src/generated/catalogs/zh-HK.ts": "e6b5775ec0676783a604a728fd9a91a33d250752f099a048d6abf8b332cfac4d", - "packages/localization/src/generated/catalogs/zh-TW.ts": "c28d362f2e3b24bb4220256c125c4e9cac7385d24b60e226d7f40544e2c11ec7", - "packages/localization/src/generated/server-catalogs.ts": "bca2564dee9d8e880a5f3d0223f1e7fbeb90db57c5236f0942d1660ca1e463f0", + "packages/localization/src/generated/catalogs/bg-BG.ts": "814f94a6713350a331d741039e03c0a65a1263bc910aa642cd81e1b03e3337d8", + "packages/localization/src/generated/catalogs/bn-BD.ts": "0f8cf444d05298eb17b8371ae12d2be3c1d8e0bd306bbeb5d91a9f74fa9fd2d7", + "packages/localization/src/generated/catalogs/bs-BA.ts": "395ad558d15c209734c68c3aff8d90a464b895ed16584dc8b3ee011f4b3d57f3", + "packages/localization/src/generated/catalogs/ca-ES.ts": "71c3b3c8210d8d711af44e166111de62b016516deb015e493db00407f86c5688", + "packages/localization/src/generated/catalogs/cs-CZ.ts": "e31d3487eb136e2160c0b91e0b9e5a436b2943f56fd96c1cb5c2897048e7ef20", + "packages/localization/src/generated/catalogs/da-DK.ts": "73a21f96f6d043dac8eec12284e5080d0e24f3f8971937149f0bc5719897b8b0", + "packages/localization/src/generated/catalogs/de-DE.ts": "80b52a12af9f1471acd6d5600fadde6cbdb15f0d4b98b524c96f43a61610df2b", + "packages/localization/src/generated/catalogs/el-GR.ts": "30cb650bb6339f4fd59f73a5e8534408717988f860e450be6120a1c2eceda9c2", + "packages/localization/src/generated/catalogs/en-US.ts": "dd456298dfda718388e502399a833186e972140d27a377eb4b8328ad5adfc807", + "packages/localization/src/generated/catalogs/es-419.ts": "ea763deb5e5225e58a6c74749b0fe22f760d4d8f8b0157e64cec47d10ec4a300", + "packages/localization/src/generated/catalogs/es-ES.ts": "6a568be1a39aeb72dad7d2fcef2d739526621df489b91c7412f302083918f64b", + "packages/localization/src/generated/catalogs/et-EE.ts": "d21ca4497e6e1188bc6a488b887e38f014ffe2f0d29aabebdbb454f49171d37f", + "packages/localization/src/generated/catalogs/fi-FI.ts": "f453c1948374eb7a8b035ccc285ec4d0bc5edf8ad4cc63528d4afe47fa15b4fb", + "packages/localization/src/generated/catalogs/fr-CA.ts": "d0cf42da8c07dedfbfae2af0d2d3805c3b7d848da823d7877780e44e0f04def6", + "packages/localization/src/generated/catalogs/fr-FR.ts": "034292dc5b19de763289ace1e6b366e8312c6d7ce9ce10ebc88b5bce49820ee3", + "packages/localization/src/generated/catalogs/gu-IN.ts": "511cf06cb2bc814025d71961780f3af11a9a10c6a09274608742bcc667875028", + "packages/localization/src/generated/catalogs/hi-IN.ts": "b333bf50f869f7c994601e89270f6e0d21e001d9a073bc9509a636f659c541a4", + "packages/localization/src/generated/catalogs/hr-HR.ts": "4a1d1000cc12c2941bd9f6a72a454a8ecf10d6eedb2eeb0dae005af995bcb3da", + "packages/localization/src/generated/catalogs/hu-HU.ts": "8fea58df45d93150ddae8288cb737302602a3e8f96e101c46edc89e94ce8afaf", + "packages/localization/src/generated/catalogs/hy-AM.ts": "21a0937da7e4f5afa7892ddf1b4a64b1470e30a6ea6886de1dc7025a52eb4a51", + "packages/localization/src/generated/catalogs/id-ID.ts": "0567b310ecc62db1111208266ca29514490771f8b3578ae291e197bc94969534", + "packages/localization/src/generated/catalogs/is-IS.ts": "a2b50106c0dc645ce99479da83376dccbf9fbf33cf4c8f72e3dad14f69f1c42d", + "packages/localization/src/generated/catalogs/it-IT.ts": "dd915654216cbba41b39b6cf945545d005979932579df2c12ef5e05749dae4fc", + "packages/localization/src/generated/catalogs/ja-JP.ts": "902782929544952441aced3a7d98e25538c09a0fa2b0735eef2fe2d128823f39", + "packages/localization/src/generated/catalogs/ka-GE.ts": "c91c50f506adc215c04f656e20c962f1d558868a38a9d2c722d4c8e097f4f759", + "packages/localization/src/generated/catalogs/kn-IN.ts": "7f23142d3642cae095767808f3d2d8531a108daedcae90f12c587682f0b8b74b", + "packages/localization/src/generated/catalogs/ko-KR.ts": "0326ce6a2abe9eb2f24d21beb77f95f6de271fd42145b1f9e812ef27c09d0b69", + "packages/localization/src/generated/catalogs/lv-LV.ts": "007264370aca86637065d761e9cbcf124ba4ec84706eaa96b97e5bf24544a9f1", + "packages/localization/src/generated/catalogs/mk-MK.ts": "8a7d85f6eb89bb9db6689991cad0594b4e5bd765c831604fa77eb01a490830f9", + "packages/localization/src/generated/catalogs/mr-IN.ts": "3971004fb4adda36a0f10adbc78598b28063f84cc5ad30b115d31b9994e1ea46", + "packages/localization/src/generated/catalogs/ms-MY.ts": "02b425f7bb806902ddce29f4d19626c4cd4a78e9cea39a87802757b82f9da73b", + "packages/localization/src/generated/catalogs/my-MM.ts": "e6c2e088a3553e2a0b7df521c96087b0a683af631b146610704d0c3ceba7cb1d", + "packages/localization/src/generated/catalogs/nb-NO.ts": "5ee316fb70d1907ca78b8d9f7c1f89f545a90cbfcc709d04d4ffb381f2a9f0cd", + "packages/localization/src/generated/catalogs/nl-NL.ts": "f2150b0880b1390636a7bbb72fc9bf2c88d5dfc390c42a0ec2ad49a691ed71c9", + "packages/localization/src/generated/catalogs/pl-PL.ts": "eb89fb952d0ae494407ca68749cd8e6ed90d2603671d4f3deb7531c30c36172c", + "packages/localization/src/generated/catalogs/pt-BR.ts": "654767d0761e1838fe691b3cef7fa8a01a6b0bab0bf013bff74b1f2251f324a2", + "packages/localization/src/generated/catalogs/pt-PT.ts": "7f414ecb5bf06b682b64c926eb543460f612542f2a448c7dc879a5c7ab2ba17b", + "packages/localization/src/generated/catalogs/ro-RO.ts": "c672aed3fba0508d52257508cd047bf6629d1c1e3cfb7c872b3a7afa2ce0267c", + "packages/localization/src/generated/catalogs/ru-RU.ts": "6ea93c16a5d6c7385a9dfae3130082c2c64646783046daa1420e40d2d81e3f62", + "packages/localization/src/generated/catalogs/sk-SK.ts": "62b0b0421e99de7b85dabf95cc1a70d71e22b281caa8021f53f1cbea21627ba0", + "packages/localization/src/generated/catalogs/sl-SI.ts": "d38da640cdad2a13827d6f03890b1a2e6f972302e7c2fc3b38fce44cb9c282c6", + "packages/localization/src/generated/catalogs/so-SO.ts": "d92af1d3aedd0ee0ee44a21990cc1fe17b5ebd7baaa0ea9c3c6084547fea4d76", + "packages/localization/src/generated/catalogs/sq-AL.ts": "5b7fce4a1d06e76d898701ee5421e8618a4b712d84cb4b02a08e62303e045cb8", + "packages/localization/src/generated/catalogs/sr-RS.ts": "a3ab829c4e8b02a7d3d0ad5867b03d4f570f766aef0de4ac7e4f74f0e065d4b9", + "packages/localization/src/generated/catalogs/sv-SE.ts": "a7d1a6da58a793d824daa9d70a4dc4b9668623ab1365963e8327a06f29ebd0d2", + "packages/localization/src/generated/catalogs/sw-TZ.ts": "f29d59f90238ec142c6269626146d65ebb3a5b80dfa07aa562a767818cebabd4", + "packages/localization/src/generated/catalogs/ta-IN.ts": "593e0cda234b4fb92a3c93dbcceab918b4b3f3a564220bd092b022eab2c8a290", + "packages/localization/src/generated/catalogs/te-IN.ts": "a8de1946eb93cc1643b7d9bd9085f05e0597fdccbc562652665fcc44fb9fad27", + "packages/localization/src/generated/catalogs/th-TH.ts": "7a0bfe68522889361e8672a2235e5dc8043ff3f8eab6adf726e1b9229b0ca044", + "packages/localization/src/generated/catalogs/tr-TR.ts": "937b0e2655b5d1939e1985eeccb3312c2ffa2105ad5c2fafb4e4def33bc2367b", + "packages/localization/src/generated/catalogs/uk-UA.ts": "606503569bde0701f3e4ea4d305e7ab1901fc18fafb62dcdde6d68826ce766c8", + "packages/localization/src/generated/catalogs/vi-VN.ts": "99dd74b81eb9b6969f37009ea37f97189fda6382d2d29efb1819aceb91203ea1", + "packages/localization/src/generated/catalogs/zh-CN.ts": "df38eb9a65c1a900abcdc98ad3e9badb6b77f520a837253a581a0d7b9fa99d4e", + "packages/localization/src/generated/catalogs/zh-HK.ts": "24c09df3f4097deea39cfeeaacf2029364bfba977c1316b42c9ebd070253ae70", + "packages/localization/src/generated/catalogs/zh-TW.ts": "c3cc74c3b8f99ad734930d5714b71fbb30f6e5e5ef98dd931f7a490c99d004bd", + "packages/localization/src/generated/server-catalogs.ts": "fa0e5b3985f7b58aaf70e169d954940df8c565d7a4ba0567d229382dcd7ae4b2", "packages/localization/src/index.ts": "daf2aa685200b1ca5163882a89d24f7617d5dae795c4d5d8c5f17ed139233b28", "packages/localization/src/locale-registry.ts": "a6b62cb7b7e2fe07e0ac6e290db714c0f19a13258aa21ffd510696867146fd8a", "packages/localization/src/server-catalog.ts": "49431359c6fa91a9f3054580a697fef9300b8e6f195da13f1eb8c150704cb1af", @@ -1290,7 +1290,7 @@ "packages/plugin-sdk/src/channel-source.ts": "2afba5974201878abb61ebcdddcdf53f2e15990048a95fd4ae4f916f26f552ed", "packages/plugin-sdk/src/index.ts": "e18fb53af83349368c4761f42858db69cbd63704993ffea3490414a010353e3d", "packages/plugin-sdk/src/manifest.ts": "77be8e6f038509d1d6fd81544123065cc3d0d0f665ffd1b29c7a778698424636", - "packages/plugin-sdk/src/memory.ts": "c93e79fca5fc534ac71dc598b0e9c38daa66486a0b463ecead58a2791d886d91", + "packages/plugin-sdk/src/memory.ts": "c96dd1e2bccd33b5ea51d3ae26fc2f2d82b8ff9fbc7d07e08e076ad0b589da19", "packages/plugin-sdk/src/provider.ts": "2f68b7c35f13ddbda689066ac3c516d682a32a6782ffc8284d61fd16b624f577", "packages/plugin-sdk/src/registry.ts": "fea3561525db1c50a61dfd700f511f9ae2e84cb28ddcdcf2e8a07ea0653dea5d", "packages/plugin-sdk/src/runner.ts": "0b0018c9f7d6b06fc77fbe30bb0ce7adc92cb3f0d5031cd848c115e13bdd3f81", @@ -1312,7 +1312,7 @@ "packages/providers/src/utils.ts": "ac825bee3c864ee9a0d1e2e32f1effdfebe653b915f0b52689231e647aba23ea", "packages/providers/src/vendors/openai-images.ts": "bf282133c3805d154e5967fef13d52bacd4405192ec7d7f30cc34faf31f8e441", "packages/providers/src/vendors/seedance-task-status.ts": "4b9667af1be831a65dd8af06f5f2d3a9e390d0af66b18b02a4a9103e09d2e5b0", - "packages/providers/src/vendors/seedance.ts": "aedb14b7ddb21ce2432caf08bb82aaa559fe4f3862de4907e5ccfb486c6be13a", + "packages/providers/src/vendors/seedance.ts": "5e2151b067ea09e5119f252075c6644f04dbf8d574ff88652aa10f5a24268bd1", "packages/providers/src/vendors/seedream.ts": "ff5aa403cd3f6ae50ec4f4e245b55f6cef17be89877f02f6116dc94b2b321562", "packages/remote-protocol/package.json": "6f3a0779f6d08df79d6891d21b8f090639d98016955538649fb1f7f791da3610", "packages/remote-protocol/src/canonical.ts": "d57248915235ca698fab3547a27a486d84fb82dd944e45fabe290ff4030535f1", @@ -1336,51 +1336,51 @@ "packages/remote-protocol/src/validation/time.ts": "c5f5937b0947d5e6c59e7c5a285dcb25cdf2d1addcb1c4db8522c17479cbeb66", "packages/remote-protocol/src/validators.ts": "7f9838d70cc8c709b643c05e32df01e8e19a08a0aa7eecb34671b71347de35dc", "packages/runner-pi/package.json": "8399c6eedd961fa53961d072133f775237054d4ead68bd9743097cd170271253", - "packages/runner-pi/src/adapter.ts": "5b50c43c11bb44b4e65e3f39b43171ce2539880a6dc3dcbfe5cb2572070b4799", + "packages/runner-pi/src/adapter.ts": "b6ba82417145da551d0ae3b25a566f4a3f2e6a6e4c03174cb75c0bd9005fd67b", "packages/runner-pi/src/approved-tool-continuation.ts": "da22e46990bea5634af8298692c17998317ed8e72242645af156a0eb93c4a87b", - "packages/runner-pi/src/compaction-recovery.ts": "6ec38e8af0c95ce7ed8c45dc02f7951fe52e836c613393063469dd757979fd45", + "packages/runner-pi/src/compaction-recovery.ts": "d94bd47510e02fe817c879106398488d40ff3a48226d920d3707020b16faf181", "packages/runner-pi/src/compaction-task-anchor.ts": "aab98f98ac29297a6aa9df58ef7428c2f44ef6470139a72b17933ac8107364d0", "packages/runner-pi/src/constants.ts": "26a9e9ac31397abef91909ad8b834d40a577cc0e1ee79e6ba24db628857a8428", "packages/runner-pi/src/content.ts": "51a175e19218666f96df70bfac471df4a85ac5218e018434ba3cd54cca6c187f", "packages/runner-pi/src/events.ts": "981888987705c0f5abb5bcd748a555e38b1f6f51c583f3b487600f41d38179bd", "packages/runner-pi/src/extensions/approval.ts": "1705f641bad7952a5d6d57e6f7c673aaae4813150284548996ea8797fb3803c3", - "packages/runner-pi/src/extensions/bash.ts": "446c97f7934ab867a511e17dd155bee685819153e0f66025b2b276843e53ad11", - "packages/runner-pi/src/extensions/file-mutation.ts": "867010737116246123f022e591d3794be63bd640c16a42b590f630c4510ae781", + "packages/runner-pi/src/extensions/bash.ts": "9bd81e1d66846a91de722f49bfc437a244135ff2b775e1626f71cabfd7115388", + "packages/runner-pi/src/extensions/file-mutation.ts": "22e86b16c7eb17ffaccb50cc5f22287b6567142da35e288dc675addd7bf3f1ea", "packages/runner-pi/src/extensions/host-capability.ts": "3159fef5c2cd8fccd0f737cd8548a4eac5f089e4cbc0314a2b9f54f0212b892d", "packages/runner-pi/src/extensions/mcp-proxy.ts": "56e5c75cc583fe5d484fd585252c2416ba4b1c27ba3cc6f51ef5ffa033648399", "packages/runner-pi/src/extensions/media-generation.ts": "fc1ad569f0c7505185a6ba0f67ee1071dcf66bc3b87b36337dbb8b88c612ba2d", "packages/runner-pi/src/extensions/permissions.ts": "4e86db1804bdc95b6785718cc7432c949cdbf671c6b633ac4bab8d3064b0a6a4", - "packages/runner-pi/src/extensions/task-continuation.ts": "5ee7e5e4a775699787896e29bad60032687d8723a002102406bd9ebe7925d799", + "packages/runner-pi/src/extensions/task-continuation.ts": "f67410b49569a02d517b06268ca2663c95dc47d412c184d4e0b8c5e32aefe89f", "packages/runner-pi/src/extensions/tool-before.ts": "4a8b0733e1a181619098bff8c91c49b7e8414dd45b7d98df00b4e140bd18d286", "packages/runner-pi/src/extensions/tool-hydration.ts": "d31facdd6d0447d27030aa7fedff695a7169f6a0a43d9930666fc76e11314a03", "packages/runner-pi/src/extensions/user-input.ts": "02000950051a7af3151052e8d3e943cd99cea968046df674d308d13977558979", "packages/runner-pi/src/host-tool-results.ts": "05e3d7e3784d83963ff989b1f36c46885f728b2ca4b94ecb7773cb46125e6c13", - "packages/runner-pi/src/index.ts": "28f32731d5e34ce0df291a1ed700f09ff3574ffc99e4f4c6050c23043cccd888", + "packages/runner-pi/src/index.ts": "fc89f0757b6c0f2d82e665f12490a9b29f6850d43799832edc00df82171a1a06", "packages/runner-pi/src/metadata.ts": "3c1a8a3cca9aebabde8e7d9e4486a456fbe9e6779368d43daf9b0432daae4d13", "packages/runner-pi/src/model-context-budget.ts": "b21aface5e5b1cd5e18291d78efdd71dd4c1300b043018087e0222436b2b19a0", - "packages/runner-pi/src/ordinary-progress-recovery.ts": "6d8e2eea4fedabe0164d7e44aaf5a5f5b167f429ea0f4714771024b5e406ae55", + "packages/runner-pi/src/ordinary-progress-recovery.ts": "7c342884c5f5315f7009b08533224ebfa59247a47f411a693a8960d25d6555b3", "packages/runner-pi/src/prepared-input-receiver.ts": "9006a084041f79881556affa9b3935bad963e9b912b291a6bf8845900263df56", - "packages/runner-pi/src/prepared-input.ts": "5743607808896d3c4811954d9461970ed0aa1822853e67f86f43e74ddd760b08", + "packages/runner-pi/src/prepared-input.ts": "685f7cd9b4f0042428daa39969990956dd5ce5eeeb31e5f039000c39a6b36d84", "packages/runner-pi/src/provider-error-diagnostic.ts": "6ef8f89831dc9b26218edcc4333a6788ef0bbe5741dd4bfc0648e7c3ef3c1180", "packages/runner-pi/src/provider-http.ts": "332c2ff8ce42771ba406b656e6cf2ba026825cba724453c57a785ef46ae53356", "packages/runner-pi/src/provider-invocation.ts": "fc5d1f5151559f824f25c06cb5ae3a2687d8cb3e31456ed3ff407497285277ba", "packages/runner-pi/src/provider-request-auth.ts": "f10f06e06566142138972f2fd199ffb0e869725e75ece45b291580aaaf13d92a", "packages/runner-pi/src/reasoning-summary.ts": "432fe71bb8e6a0b7ff844781db4df006a039c8e4b8cd8eb48daffbe5b355a8ab", - "packages/runner-pi/src/resource-loader.ts": "40e7d1626cc3e48229cd30b77301338980b9b8f0720fbc3e71301e7ec20c6966", + "packages/runner-pi/src/resource-loader.ts": "8485ad1039273724721771bd69ea2e4f277aa49eb2050326c09b3eaaf9a32121", "packages/runner-pi/src/session-continuation.ts": "d7a6ebbecd8c7244d60dc8e5f5be1639810111196ee3c72240947b9ebd23f4c6", - "packages/runner-pi/src/session-factory.ts": "5f5574a197b4df8a3590b5767bd1fb90cb7f81ef7f6b5f55d58116e62d78572c", + "packages/runner-pi/src/session-factory.ts": "c4e655b7cdc87743c85a7b6738c0dfb83a1f79c0fe78e53a8c9ca07fb11d090d", "packages/runner-pi/src/session-transcript.ts": "32a6320910e5949c25f730eba157ccac5c1661622baa006cecb964d486b41884", - "packages/runner-pi/src/task-boundary.ts": "45101bf6754fd7b882aff069aa71d82141b6b396b1d4ae2a5c22af8fbc676653", + "packages/runner-pi/src/task-boundary.ts": "7cd786e6d5b7db7085734a9240814c621c96ee9be05e3984f6f72b34dc9b0f07", "packages/runner-pi/src/task-yield.ts": "c563e68a46609aaf58534c50d67aea4794ff6d8ebd35bd111613643ec7f1d00c", - "packages/runner-pi/src/tool-boundary-observation.ts": "82dde2196b3367fe1c2cf3627f3863aafb3154f5db9c9edc688a89629ef7a2de", - "packages/runner-pi/src/types.ts": "a3b846298a7ff0f55843fa9bc492b73e15afa2c0155f406a8448a4de70ff07bd", + "packages/runner-pi/src/tool-boundary-observation.ts": "0c9f4bb696413b9a44452524b8dc0071ba4aa5bc9fd2bb960571320c0522bf79", + "packages/runner-pi/src/types.ts": "aa77703718e2cb63588d755f10c8602ae763f8c716a8fb00d8d5d1ddf1e4c658", "packages/runner-pi/src/user-input-request.ts": "1020fb10883eb7166d8b886337120a9fca5ddf8e69c99221f2a27aad8f2d7f4d", "packages/runner-pi/src/utils.ts": "c994399e7a85bb6feea3710f0b744aee717d9d48c8484cdd1573e3215eb3d0b1", - "pnpm-lock.yaml": "f8888021f9d124c5d5979857ab1fb8531f22a6c45095e23eed16147a72d59cad", + "pnpm-lock.yaml": "a38f6ec822c7641d8040451f4a822381629ac55abb01d1e4bacd02ccd3eef5db", "packages/browser-host/tsconfig.json": "ba10bae6415c98673a85ed99d9dc66da33bd0123c644b64856b44fa2a2dce053", "packages/contracts/tsconfig.json": "463f23957c8696dbce5c700e6bea9a925d360d9fb38e62f79ed8af7dcfabba47", "packages/core/tsconfig.json": "ba10bae6415c98673a85ed99d9dc66da33bd0123c644b64856b44fa2a2dce053", - "packages/daemon/package.json": "a5ef8fb5969cc018f81048c6268a8db71f914a613ef812484daf1e8517cdf377", + "packages/daemon/package.json": "3e686765747dd5d062e41c7b08e02af0e261547faa29208c0b97005240cfa80b", "packages/daemon/tsconfig.json": "c21e6ed0df45934df795c951e772fe55366b8e655415d823c1e3220a7258274a", "packages/localization/tsconfig.json": "a14de6e8e5e3e178d0d9b732fa35667999d5a35eaef62a624821142c299e5df8", "packages/plugin-sdk/tsconfig.json": "463f23957c8696dbce5c700e6bea9a925d360d9fb38e62f79ed8af7dcfabba47", @@ -1390,13 +1390,13 @@ "packages/contracts/src/execution-engines.ts": "c64cd33ed0cc663f37f2f10d792145e5abe2f806191ab6e2ce18a98c10a65d2c", "packages/contracts/src/runner-acceptance.ts": "14941aba9693b6236860b6841a4e605d4543151219086072ce85706b8afc1988", "packages/core/src/approval-decisions.ts": "d17e04762f3f0693513b7a3bdd54cf209c6d4adcd8fea0d42ed2a29ac2a66821", - "packages/core/src/execution-engine-host-authority.ts": "05ddb9a9ce671d8c45b500f240ef25192f98daa043f72d7dba6cf7bf6afa624e", + "packages/core/src/execution-engine-host-authority.ts": "b0f8ae6fd5e16ad5c7f3ae84b4e5514bcf78c324e1077baa5d7d106bce45cacb", "packages/core/src/execution-engine-profiles.ts": "75958668b41b41656749c84e5ce817450e7374da6e5ce24b87082aa7d0990ff8", "packages/core/src/native-artifacts.ts": "9fa47fef864ec806fc864d88820900e6a789ad05452a4f609e89fd66633b1ac8", "packages/core/src/native-execution-types.ts": "e8cbb5c20050124e0ee1d85b6030020c4909a54e7082e7af979e8ad955f9adf8", "packages/core/src/native-openclaw-session.ts": "50cdf25d97a6e559f6c0176a2f4408ef31a74f7d86d4e64eecff264f125cc32b", "packages/core/src/storage/execution-engine-profile-store.ts": "a43d0a4f9d91b97899d3d013c044e6270a25ed746051b412ba1abe3ba144d39e", - "packages/core/src/storage/sqlite/host-attempt-native-coverage.ts": "1c6f17ff871783beac0731e51d9638f3505b43ad905f9664848ee62894951f63", + "packages/core/src/storage/sqlite/host-attempt-native-coverage.ts": "d34479bc22a71494456d9be1431b1cd7b786f71979b80ef1c5996c74e3ad1606", "packages/core/src/storage/sqlite/native-approval-wait.ts": "00540c5d4dc4540a129d6767bd48b0728bccf787e17b3630d7ce054791e03daf", "packages/core/src/storage/sqlite/native-execution-migration.ts": "4c12c48e082a44ab70b68d63b6ca2ec4d44847e33c1d63b418920d617b6bacb9", "packages/core/src/storage/sqlite/native-execution-store.ts": "553edab4332137c9082c17afaf74e5461c30ed21bebb9e8cb9021c6547ae483b", @@ -1405,11 +1405,11 @@ "packages/core/src/storage/sqlite/user-input-admission-policy.ts": "58d69bd228c560dab6ad9b5660dddcb58e059dfaea452e2bd38a54f7ae9a7c9e", "packages/core/src/storage/sqlite/user-input-startup-failure.ts": "03ded94f9a802d5c478c7409d49d1b4e7984c7f502837c5bbb4ba5e6978c7006", "packages/core/src/storage/sqlite/user-input-workflow-admission.ts": "f6ff0970cd10b0bbc808b6584a5c9357c903772c1658d7e16a456c3420282bdc", - "packages/core/src/storage/sqlite/work-summary-migration.ts": "10c071fa5d743e3c299fdcb55f86bd90e3489f3babd081d8427280cf5662675b", + "packages/core/src/storage/sqlite/work-summary-migration.ts": "c16d9dc7b28490ef1a4d2db25c04ec44bc5bedf264609e724c05470cbf2578f1", "packages/core/src/storage/sqlite/work-summary-store.ts": "da2894cbdb6958b342297510bb4f0224addeda424ac3efcdf90eb01f8f6b5b88", - "packages/core/src/string-boundaries.ts": "aee1b601ff02ae63224f14a91c24a4b6c855689736f263ab7e219d59511aa048", + "packages/core/src/string-boundaries.ts": "e4b5a0ca3310371f82b4cf53962f1bb794a3faff646045a9d8b21a8e4608cce2", "packages/daemon/src/features/conversations/conversation-execution-selection.ts": "bf68bc0c92adcf6569a0a6e4f5deaf5505682b2fc484a7d331be4dbc441a7d05", - "packages/daemon/src/features/conversations/conversation-run-request.ts": "44a325fe8498ccfcd5adf488336fd6e9cf8de99ec724e21cf1ed5dd1b039acee", + "packages/daemon/src/features/conversations/conversation-run-request.ts": "4b6ba4fd25a6e289b14bb2c924c1a2c5030ea0a3b01c15e8a46bfb2cc39595ba", "packages/daemon/src/features/conversations/native-response-preferences.ts": "8b68eee4d21c44bcf0f25c275b0a0c8b226cc49475d0b483738f542974c3dbff", "packages/daemon/src/features/execution-engines/acp-connection.ts": "4f7ddf11649de838e65197c8b89aea47efcf3847adcecff694abc7bd17f339f5", "packages/daemon/src/features/execution-engines/acp-probe-transport.ts": "dbf3f94353be0444359a7eafdecdb98fbfb52d76c904e5185302970867931989", @@ -1421,9 +1421,9 @@ "packages/daemon/src/features/execution-engines/codex-connection.ts": "d3bc90c3c6da22cbde5d235252c80574eb4abb7fe4630345eb7cd49243f966ea", "packages/daemon/src/features/execution-engines/codex-login.ts": "8e9001f517a6be47e7a29d00c8c1388a6fcc249312e1646437c54081ad97c2d9", "packages/daemon/src/features/execution-engines/connection-probe.ts": "c728fd7d2b846b30deefc23da47cc7652a07d57bdf59af5dd030fcdfbfdb7c23", - "packages/daemon/src/features/execution-engines/controller.ts": "75e2fa07f14d39eeee57274f60ea6cdbd3f297772ce8d9a83dcd710bf629c134", + "packages/daemon/src/features/execution-engines/controller.ts": "eae1db67f3571e308c39a40f249f2ab41ecdf80f700a441673f82a341836dabb", "packages/daemon/src/features/execution-engines/engine-secret-store.ts": "f77d0e3a527329167e827c268f59e3d9ae74b68a4a54518078568109eb2c23df", - "packages/daemon/src/features/execution-engines/feature.ts": "c14b03056671ad9a81f43ef93e614ec687bf503539e735fb86aa8a3b9ce20377", + "packages/daemon/src/features/execution-engines/feature.ts": "f1d7a2c1cfb8f7f01ba7c1bcb1add7fea4638267176e8b63353e13e11649e5c9", "packages/daemon/src/features/execution-engines/installation-probe.ts": "b0df0ee7bc605bee12d8e68adea5a7b1ec8ccafbf7f61202c9468374e2aa5ec6", "packages/daemon/src/features/execution-engines/kimi-auth-state.ts": "b775d129f9cc7d8c87dbeca6589860a3d592d56ee0a05ec012d9130eec18857c", "packages/daemon/src/features/execution-engines/kimi-connection.ts": "1bff2d8544e50f01de975fa3b0b53989f94ff278737cd7da61876176f27ec6b6", @@ -1431,30 +1431,30 @@ "packages/daemon/src/features/execution-engines/kimi-probe-isolation.ts": "0aeeec9d9c3594a0324c329671bc23a43103f11b257c7c6ffea9a7e765c790c3", "packages/daemon/src/features/execution-engines/kimi-terminal-login.ts": "8a0fafcc5bd4356d22781d089402a363a14dbdea252772af556d73777d163468", "packages/daemon/src/features/execution-engines/login-manager.ts": "0862f63de9bd72c74e741e5b643551835ff053db4b03eaaa76d9070398c2097d", - "packages/daemon/src/features/execution-engines/native-admission.ts": "7d932863d7c6b8ecd4b21236d7da08d7260aea9910dbaed833cc3f8c5d44780e", + "packages/daemon/src/features/execution-engines/native-admission.ts": "7684b989c6ec22d7b4b984cda63a3970334bc69a91d30e98d6de27d827fdfa93", "packages/daemon/src/features/execution-engines/native-auth-file.ts": "f7ebc6b8fd74c191d546bf2df8b94b3742ee999aad40e56dad5c6c90aaa374d6", "packages/daemon/src/features/execution-engines/native-auth-home.ts": "7a1313a419e17787bbec2fabef0846b93024dca6f586543616cc6781a22b458b", "packages/daemon/src/features/execution-engines/native-login.ts": "47809934eb236105cc4328a842b869818ad760bec39c698229034db4f558a163", "packages/daemon/src/features/execution-engines/native-secrets.ts": "667c5a32d5897edad81fa0826363ae70ef52a4f1b9ce752b558a97280261f743", "packages/daemon/src/features/execution-engines/openclaw-environment.ts": "fb973b9eea7458a6bebc39b8ef0bc9c2ef85242e79c3f70160a2eb34ad07ee08", "packages/daemon/src/features/execution-engines/owned-native-process.ts": "cdefc541190ed1a9c31ebeda685b709b1d8199867afe2901a3e4bcff5462c38c", - "packages/daemon/src/features/execution-engines/run-target-dispatcher.ts": "7e982a422e3d06ad0a1e079504bb273db7591532ae2fc46524511ffb0b55bfc2", + "packages/daemon/src/features/execution-engines/run-target-dispatcher.ts": "6f77d1545216d5f1a3e2fa91ad6434d8ec1a4adc25f213208865854b96f8d53c", "packages/daemon/src/features/execution-engines/secret-profile.ts": "f5192c01280f9b53e7a9e0f11d81f7b38a3e5a79e318e85bfb758c7a6ab7170f", "packages/daemon/src/features/execution-engines/windows-supervisor.ts": "d4b18744c2417650d909728bf53b66c8387a6c1a336456060b840fb684b15da4", "packages/daemon/src/features/runs/approval-action-policy.ts": "13d14810325170b8b5e4494f1cc26a6cc28b7ad278b785de59d28ea2f7c33492", "packages/daemon/src/features/runs/existing-idempotent-run.ts": "0cbb460dad3e7f2eaaaa3225dd475ea59fec846a4ca17f516ce43a8a2f180156", "packages/daemon/src/features/runs/native-approval-events.ts": "f5217f4cbcf694c3a58f942286972d6fc795c58a7bf1596bf180dab9eb1d9010", "packages/daemon/src/features/runs/native-conversation-selection.ts": "74dbe860a28075b28377d4a5abe64752feaec616e7d6bbd836a642e0f5eee825", - "packages/daemon/src/features/runs/native-run-dispatcher.ts": "ed55dd6e586fbbb129c716ec1d28a5599e75d865ad744619301d59a52b8a5de0", + "packages/daemon/src/features/runs/native-run-dispatcher.ts": "644cd6bcc3cc7b1fb18dee1ba3b196fd99f3ac818e9dd4c1617ae8ac48ca03b5", "packages/daemon/src/features/runs/preparation-thread-failure.ts": "95f7e8a6998cdc30415c7af0354c1890f96dcb4f3ce1b577e80eba2ff8d0cd82", "packages/daemon/src/features/runs/run-ready.ts": "c04d1aacb7f1ad63b7a7a99ade0a49a40b2987cb14ea8926793c672270ad1387", - "packages/daemon/src/features/runs/user-input-answer-dispatch.ts": "83c717ac1d9bb1dc22d49128d0a8213190be49439e88fc14231f53a8fc8a0a6d", + "packages/daemon/src/features/runs/user-input-answer-dispatch.ts": "5891c9daa5a6091a9ccab30fd3e1158fe2252557f46ba6586f12ab7e3a2cf600", "packages/daemon/src/features/runs/user-input-answer-routes.ts": "6a7fcf070e9713fe30af06ae6238139868852956c51be8ed3a10f73715ddcd8b", "packages/daemon/src/features/runs/user-input-continuation-context.ts": "6cddf5ac2bf1988d1547873960671e15dea3187a0f86535da14042f29d085bec", - "packages/daemon/src/features/runs/user-input-continuation-receiver.ts": "d74e26d22c5660e7e1eee74c8022f9093dea462e0e507cec32603bf263058438", - "packages/daemon/src/features/runs/user-input-delivery-worker.ts": "da632a924329ae8315261c708ccdb950510d7cb8a195450b74368404e3c1e686", - "packages/daemon/src/features/runs/user-input-execution-preparation.ts": "61dfede0cb8fabf566171edd083a02525adc974ec5c70239c069c82943d0499d", - "packages/daemon/src/features/runs/user-input-prepared-request.ts": "0aa889037c82c4bad8cd41e04b7cd62d3bd856cda3dfd7ba647b314b98416d34", + "packages/daemon/src/features/runs/user-input-continuation-receiver.ts": "926a88b92afdac0362999b61814215c2a6e40ccd92fa842ab6e0b51763d79199", + "packages/daemon/src/features/runs/user-input-delivery-worker.ts": "ffac87ddb6547300ea7434ae85c5bc47e523da901728720e7b07c26feb439a61", + "packages/daemon/src/features/runs/user-input-execution-preparation.ts": "f2e9584dc6f3dc31017f5837eaca30cc7a9080ff5c764d574593a71b26d66a07", + "packages/daemon/src/features/runs/user-input-prepared-request.ts": "e019d34c74203e4b4cf49efb4d206c47de69a35634a633619b9e64e72bff0861", "packages/daemon/src/features/runs/user-input-source-execution.ts": "6c12030f19a76a14578f27b26662b06aa4d15e718e44e6cf0b1a4070baac8c87", "packages/daemon/src/features/runs/user-input-source-policy.ts": "f0dff713cb0f1ee0af840ecebfab45bb20a159d237abcf886ca460de7c6239e2", "packages/daemon/src/features/runs/user-input-workflow-terminal.ts": "fbb0faf89f33c5557579ffe92b4c79d81532478748609a4afc6a9af9e0246f29", @@ -1471,18 +1471,18 @@ "packages/daemon/src/runtime/acp/host-terminal-process.ts": "d8933b028a9145b1735ea50dc7c5e8312e51581581a2926377463ea20115c49b", "packages/daemon/src/runtime/acp/host-terminals.ts": "293515cc8ac1493c932aef63417d0e8dd047d29bf3bb2738f5866fbecf80ed6e", "packages/daemon/src/runtime/acp/host-text-read.ts": "0fce68e2a7a9ec9cf482ed85ec64d221936210f1bbc540b71f7364934ff77244", - "packages/daemon/src/runtime/acp/host-text-write.ts": "5dd89612261825c3b74666647f19ec633c361608c6f4b02a241a72b3da3944d9", + "packages/daemon/src/runtime/acp/host-text-write.ts": "e0ae227597647c9ab3a21dd05f2aadb5d8126d59216b800ba9b82e85740fdfc3", "packages/daemon/src/runtime/acp/journaled-lifecycle.ts": "311710ac664be1daa6806c46173d1bdafc6102c2d753105d3809623bd659ef53", "packages/daemon/src/runtime/acp/kimi-action-preparation.ts": "c8d1b7348129a2a1799761639b8297fd3d712ca3bc5fa0d11a72b5f351496c04", "packages/daemon/src/runtime/acp/kimi-bash-actions.ts": "3bfa999648be990b2f3ec2081abddceda3553a9c6d20f4bc3b8672d0d4ed4d43", "packages/daemon/src/runtime/acp/kimi-execution-home.ts": "b1f6d6f04469c6cc364ea3a187fa94aca6a897f569e073f1bcc718329585dbc5", "packages/daemon/src/runtime/acp/kimi-file-actions.ts": "3489118e5096b09029e3c58416848cc76b5e94a87754835ec47adc8e680c20d3", - "packages/daemon/src/runtime/acp/kimi-host-files.ts": "c7c2b16dbc3b4b55bf268fa726e2db445c761bc3daf0c855c5c6d6275939b79c", + "packages/daemon/src/runtime/acp/kimi-host-files.ts": "5440c56479cdf9944e795383248e7cd69f0ef95a5638e1ca0b8307c7eb66391a", "packages/daemon/src/runtime/acp/kimi-host-terminals.ts": "911fc1d45e4998b965faad2cc9bc015429b3903a038bfbfa76f3fec9d7187d61", "packages/daemon/src/runtime/acp/kimi-model-configuration.ts": "280429e24e59529901e047ce8ad5be600e62630b65bd29a35d6d66bec6b94137", "packages/daemon/src/runtime/acp/kimi-model-transport.ts": "e6df7e9ff25d93243440e42aeb31a43bbbb193942dfe5ddbab7745b6ec5c8b1c", "packages/daemon/src/runtime/acp/kimi-oauth-credentials.ts": "698fc753fff41030660dca7a1f8b6d758032091397fe84e5ef182d6ad4ed2234", - "packages/daemon/src/runtime/acp/kimi-owned-execution.ts": "e43e0ce369c36e3e97d12d9686656dd5843ca2635b54cf0867fd924f06c22501", + "packages/daemon/src/runtime/acp/kimi-owned-execution.ts": "dd269bcec7e74976755f3c40af4e0da809acc995554451f42f060bb68936691e", "packages/daemon/src/runtime/acp/live-client.ts": "902f06946b4e4d8b264ab4b541a5f8ed509b1b9c04ac61c5351d5a911a067bc1", "packages/daemon/src/runtime/acp/live-errors.ts": "7f4348e08e72975b9a49320d9979f0bb4157cfc2a1a05048a94e985a88fe5528", "packages/daemon/src/runtime/acp/live-stream.ts": "dfa7e1368dae2c1bbb107d101a5456cf05587142455b3f5459c2aa54acd1178f", @@ -1514,11 +1514,11 @@ "packages/daemon/src/runtime/adapters/native-tool-content.ts": "30f0c028e69e1ea754ea32507695b417f52792e4e3b46f470938575278ec855b", "packages/daemon/src/runtime/conversation-execution-route.ts": "f5c8aa930b79691496729f9d133668c4c99ce82785fa70940faa0c6746ffe408", "packages/daemon/src/runtime/native-runners.ts": "b09a21d63510a93c996ebf90121fce4ac6154e2194e791b2b982c270efba264d", - "packages/daemon/src/runtime/run-execution-supervision.ts": "9d0c0a5f14f1e03aae21f17525bd9114f41d33e4212ed6021bdde52363b5bf7d", + "packages/daemon/src/runtime/run-execution-supervision.ts": "2932a45986db8b14c02f6bb1ab8daea4b3b19cbf929d6cee7a06df98e6da6ed9", "packages/daemon/src/runtime/runner-acceptance-store.ts": "b71c6b61ad2460a9850198548681508f8dc454135d45f9e094e868ad4f7635cd", "packages/daemon/src/runtime/runner-acceptance.ts": "d5790797673095ab286b29880f8a58b72526e78f4e832f365cb73c3adc6cf6f5", "packages/daemon/src/runtime/sandbox/native-process-launch.ts": "329ee408f790d1e5a19aacc87a914e392813a13f1e544a37804cf30a72894c41", - "packages/daemon/src/shared/html-text.ts": "7e1618867d99492f1fa3e4e2027215108581cad50ce88e4a6bdf39910703db4b", + "packages/daemon/src/shared/html-text.ts": "7f60e054030a40f98cc4f9af2eff4e7f2cec62c6e8ccd01748ad6514b64478c2", "apps/desktop/scripts/distribution/installer-builder.mjs": "f7c408d5ba05ee997cd8ff323d9b2f74af06d24f8303905ae6c3ba6546284883", "apps/desktop/scripts/distribution/LingShell.nsh": "869fce8f300a4d3a752ea5dafec591cb15d9fc6bd996224b97f26e0bf751adf3", "scripts/gstack/testsavant-model-cache.mjs": "1044b7239dd08a321bf8a839e3c72bab21843bcfcf28c929c0250266a217a1ed", @@ -1526,13 +1526,13 @@ "packages/daemon/src/features/conversations/context-compaction-projection.ts": "864e2ff72cf2af7eca0c8bc3aa54130324aef31e20b02de703166888dfe5cbe4", "apps/desktop/main/ipc.ts": "aaff892eb55dfb48925a24389dab9ea56afdb72e64c10ca6e748a8ff8ad0ef8f", "apps/desktop/main/local-services.ts": "5ca0bd4c4a59c5e984f3e7546ab4233228000486568c13dbc306ba40791c439c", - "apps/desktop/main/main.ts": "8eb36ceb9f6e30899d38ae4d4fe1e38b863a95ba2c33294c58af6384580a69c2", - "apps/desktop/preload/main-window.ts": "0e0a54c4ac2082a096768def9dfe7200a04d98c2222e3f58901e6027293a83c8", - "apps/desktop/shared/desktop-capabilities.ts": "fe45e4bdd74959645461655b58f26383997c7172f44e7a400e298d6aa9d91210", + "apps/desktop/main/main.ts": "23fa7a0287e69f590be37ce3e9e6c976f894f3586e0723cae0760940b2574011", + "apps/desktop/preload/main-window.ts": "d575a325e88b7941d2fff7263b8d70c6b5ecac22795fabbfc59457a542782571", + "apps/desktop/shared/desktop-capabilities.ts": "99fe5a8581e86880c80c62726413c083be4e7ea7792f864a7818f89fea870a30", "apps/desktop/shared/ipc-channels.ts": "94145e2ad41c3348ba48cf832ed7dc32fc5de91b94cd14bb853d509928a8fd55", "apps/desktop/shared/local-service-capabilities.ts": "21a35c2cb964855ea0234cee8ccd6431ff42dd1cbe55ee54ac410e4cf0af4616", "apps/web/src/api/client.ts": "71a0847fb3bd57532653cde0ba6b76a395d0d0dc7746d3bf7f2a2a572b967c81", - "packages/contracts/src/artifacts.ts": "6bfc343e3c275de4f47f63d8d58fd179b34cf5934feb83c5f62efce7ac919a1d", + "packages/contracts/src/artifacts.ts": "fc027c519875d5b564f694528d96ba7d82fb9ee081137ec0a158d183d88b6b41", "packages/contracts/src/channel-sessions-validation.ts": "8351a069c51b57362bd1dbd121ba05574fe6ce4b077ed362d3e8e98380baa962", "packages/contracts/src/deliverables.ts": "20e94c59dfcfc8257f59cbb18559472e6c223638ac9d6478f80fb4eca752ac5e", "packages/contracts/src/engine-lifecycle.ts": "ed8548e2fb015e3a823ee962b0eff4cee5bc55f69d5bd48327d4ea1a06cbe68c", @@ -1544,7 +1544,7 @@ "packages/core/src/storage/sqlite/channel-session-lineage.ts": "e155a7fb3ee342f5821f1aa03e247e11c9a078741c5b7693ab9d2d3fd7fded98", "packages/core/src/storage/sqlite/channel-session-migration.ts": "ef12ee32ef5e5e6040c7638b8f1dc61d0b11e87709d77335b5b56a4f6a38d9d2", "packages/core/src/storage/sqlite/channel-session-notification.ts": "607b50a1c893a21d5db3cf5bd1dea8069bb9ba9f58976ee0b01efc2e4de6220f", - "packages/core/src/storage/sqlite/channel-session-store.ts": "8b9d7aeb0f1ff114f8fbded6bc30fad4460fc0994851a64cef1f5a8c5f46d674", + "packages/core/src/storage/sqlite/channel-session-store.ts": "a7927d3976514081bf93a518461f20f661f2d55c01a0f01fba6df6e40ef6a98b", "packages/core/src/storage/sqlite/runner-id-migration.ts": "8ea87bd78ff20104402af8b011d15f0b99d7dbf6895d2469f13487be87b546b2", "packages/core/src/storage/sqlite/workspace-permission-migration.ts": "fc20655e480a08f7cafd304ea804602666bf88f9d74ee8c2d147b9e1e9e7faef", "packages/daemon/src/features/channels/session-routes.ts": "3c96cf63cfd327b767aee5f11a55be9d4165fe6c5af8a1e59af8b58ad593b86e", @@ -1553,20 +1553,182 @@ "packages/daemon/src/features/deliverables/spreadsheet-extract.ts": "d6b6a6d74f7292599291bf9b10f524ba1ab1d5c7a6799504a55e108e09fe9d90", "packages/daemon/src/features/deliverables/spreadsheet-manifest.ts": "52471aa93c541728b2292b4b7e77866dfa44265056273eab14d5f894de7e211d", "packages/daemon/src/features/diagnostics/routes.ts": "04d0634c46b1eb713b7a2053e6070a74612a3fda45d7deb2f4745b07f9dc230f", - "packages/daemon/src/features/execution-engines/engine-scanner.ts": "b0183a0ff85da0b69342e42c0200290129ae85116e34dec21595f225782123e2", - "packages/daemon/src/features/execution-engines/managed-engine-installer.ts": "1b3686cd91b02509878aecd063c707e3e834638fba68b71335adbc986549845f", - "packages/daemon/src/features/im-destinations/registry.ts": "506ae8962a7406b54ea6aaf8b38f116bf57d0590844d3cb3d3970f1d3d5b5174", + "packages/daemon/src/features/execution-engines/engine-scanner.ts": "bb4c2411fe0197b6e9f3faa35b299f4022bceee6dce81e4c704b76915e8e4370", + "packages/daemon/src/features/execution-engines/managed-engine-installer.ts": "e68925e46bca32016941db18cb162246803b3008e90cf57cc02b9b7186f984d2", + "packages/daemon/src/features/im-destinations/registry.ts": "cd33c86ddf4d64a52fb42b39c3474e2eb6d6fef8976db085e1feaa3897eef18d", "packages/daemon/src/features/im-destinations/routes.ts": "41c33ebf7ce874524446ef4770f2e65732328eac4770dca80c0d6a0b7be756db", "packages/daemon/src/features/media/workflows/media-protocol-contract.ts": "571e1ebaa70d473ea9b4bd4ec35eba9afa2c8a25ce6254b390c6415660d2f15c", "packages/daemon/src/features/notifications/hub.ts": "7b335bc281da7692c3b8ab47f869dd5e562bcf0d855454631051186fe4721d15", - "packages/daemon/src/features/notifications/routes.ts": "66779a4669b9820655389922b3c8df704a919fd62cf4ce2f99427ee0901e61f1", - "packages/daemon/src/features/skills/marketplace-route.ts": "f9acf43750b1f27c2a70f562f3d03ce0feac7d9a73e76e8cb477eec6adc770c1", + "packages/daemon/src/features/notifications/routes.ts": "dda704093ea520c7c8e3141440503af13753c04aa99b650f75286d6b21b2da12", + "packages/daemon/src/features/skills/marketplace-route.ts": "a9a0b0db9fb4ef7f30b80a42ed0835c10ab5e002c31ccafceb3cc868bf0f86f6", "packages/daemon/src/shared/permission-contract.ts": "6d17d0c20ec1b5e7be3e40199ebe540bb86d69f6961b23c9d0613d428773cd2e", "packages/core/src/runner-ids.ts": "0b381a79bff82f46b25428fa35c8fab45f91bd9c88e4cac9031781d7aec47434", "packages/daemon/src/features/channels/conversation-runtime-metrics-scope.ts": "9ee64725df908167da83b4fc32cf0a3532a9ab4d0b3702d53c7aeb6b9cf15078", "packages/daemon/src/features/conversations/all-turns.ts": "5a0c25b6b29c1844c33476f4748be06439744460279e96eddee98ca3ba75d0c7", "packages/daemon/src/features/conversations/runtime-metrics-projection.ts": "bddaf83655e5507fbf7675bc3981c590a1e545394ba383fd5f2a1ffa28912b21", - "apps/desktop/main/daemon-lifecycle.ts": "aa97586cfbe8852d3de4edb4963e849465b76b1511b4b953e856b3acb0858eb2" + "apps/desktop/main/daemon-lifecycle.ts": "590acdefd8d8a5672ddfffcc71aab6a508f425bee7ac9a2ed1213483ffcb83f1", + "packages/daemon/src/server/request-auth.ts": "5556967672c2ea67a60ceba846eac6f40c5188d7d1d288975aa6b4fed9a91e8c", + "packages/daemon/src/runtime/acp/host-text-write-worker.ts": "8269454afe1baec2712b4d2bfaa583003a1a77edc116c9a1048c82e2977e470b", + "packages/daemon/src/features/runs/run-execution-sink.ts": "f8ff874682c174e3a0a961c5ff7cc9b3d1959c8ce8e9371d671ab62ddf0d4415", + "packages/contracts/src/diagnostics.ts": "48a3c4a1073b7fdfa6b8e691b5b188ce8ad96fc36a1a879481f949c831fa7360", + "packages/core/src/channel-pending-operation.ts": "e5de7930e8dca57fd69acc6ca8c9e98adf81aa27243599deb8ced828a9c3849d", + "packages/core/src/storage/sqlite/channel-pending-operation-migration.ts": "31a694acaef77a47ba2a0c3a9827b72f195741f0d1d997df6b007fab539579ef", + "packages/core/src/storage/sqlite/channel-pending-authority.ts": "2d369cfef76de3c104eb4dd54cf9837326fe8df1f130184724fbff0c017e7462", + "packages/daemon/src/features/channels/channel-pending-operation.ts": "81c9413e6ca1699c8ce9422993c0d9605dad76b2bc28c1a274a6d0ba784fe446", + "packages/daemon/src/features/channels/channel-command-input-policy.ts": "65f50cbd1dbd20f78102d5671dd212f2fcdc38c19af4a9a4dd8053ae83cb5e3b", + "packages/contracts/src/learning-reviews.ts": "2984c493fa51d0fd51097e94ae5d548c93e217b14586f49a28d3cfd5db79ac63", + "packages/contracts/src/memory-derivatives.ts": "f139050d0caae1d4fafb636f5ad7f15c27e44ac07ae82a27d95e2d1e50c4656b", + "packages/contracts/src/memory-wiki.ts": "714e4f19800d77621c38cdd0a9ea28b02adb4977db5c489611a499ae5723023d", + "packages/core/src/learning-review-types.ts": "a26f01d4f409ae2e8b29d8508e2d52212c79e5188398dc40442e1c963852163f", + "packages/core/src/memory-derivative-types.ts": "729d451b8677dbc4bd44dd9f3ffe84d329704e9e6f95c0decf4edcff6a989a04", + "packages/core/src/memory-wiki-types.ts": "e9d034adacfaba92b653a0169cbb6840f672e00f510aa6e5c4d975ccc925dd66", + "packages/core/src/storage/sqlite/learning-review-migration.ts": "00c67ab2e93df7ff257f379cf91a8f6e9ac049a89913ec1a4b3413716c438b14", + "packages/core/src/storage/sqlite/learning-review-store.ts": "c2e43d2917c74fa4296a682f9967c90ef2ee3c74dc59b371dd0bc85a5116b693", + "packages/core/src/storage/sqlite/memory-derivative-migration.ts": "e8a9433994935e98d39bec4d27fafc3084736db8b433ad5fa2ea969524071545", + "packages/core/src/storage/sqlite/memory-derivative-store.ts": "0e4af28508d1e93e92b44f0795f62bdbd965ca01bb71f4f27a1432326a38c2fd", + "packages/core/src/storage/sqlite/memory-search-index.ts": "0ff76e37f36758c44fa6f885dddff6e817d902756480fc28b8ede224c41bd5c1", + "packages/core/src/storage/sqlite/memory-wiki-store.ts": "6b2607cb1e7776ed407f77ef1127c0375ae816ec5637767884e60e3b41b74c41", + "packages/core/src/storage/sqlite/procedure-observation-job-migration.ts": "2409494ad333693f9947d3c753d1b8e09f64e497a398c7a58778860b2acc05cd", + "packages/core/src/storage/sqlite/skill-definition-revision-migration.ts": "5777c5f1a5b76efd90e25bffbb3a49f2e98dc1e46baf12c855040e2fa4a37a4f", + "packages/core/src/storage/sqlite/skill-definition-revision-store.ts": "cc95a363be84c7745977aa31dc37e56ef29efcdd81affa9962763a0cc8f9f8f3", + "packages/daemon/src/features/learning/coordinator.ts": "490de6add80cdc36095ac8fee1dafc059edecf72a1512dcf8bd7ac4c06830138", + "packages/daemon/src/features/learning/evidence.ts": "6221927078417abbb3f8e02a4695a92e75dc8b5662948577b1aecd96678fe0bb", + "packages/daemon/src/features/learning/extractor.ts": "46ef347202e2570e7b989942f0146a9072f4343a0006c62ce8821d5ee9c5b42e", + "packages/daemon/src/features/learning/model.ts": "455c4b1fbc4b231056cd8ba68c75cce02d1f2c7c330bd6811e6ed6bd50d3135d", + "packages/daemon/src/features/learning/routes.ts": "e81abfe494a36fd78e2b290945d7f58e8c7a910ae24339f895a1def39e6843c6", + "packages/daemon/src/features/learning/scoped-candidates.ts": "457fb7a4f8ddde9080e00cf04c94bee73e9f55854daa570135d8838fd59c3ced", + "packages/daemon/src/features/memory/embedding-client.ts": "6fd5bcdaeee6e9b4762b2c95457f1493e6b3029c380b03708594e57da96948b5", + "packages/daemon/src/features/memory/lancedb-provider.ts": "d199ea375732266f511cab59ec97833a376b076308de7f504160ed0f5d452638", + "packages/daemon/src/features/memory/recall-service.ts": "61a23db2cb6cd0f1d8da98a0a3c8d6c580a0f88884537f50c6dfc9ea3e149a47", + "packages/daemon/src/features/memory/semantic-registry.ts": "62b7edc16a8e8c816a1e349feb7dc88ed394ed561fa280b2c6153a9420fa4c18", + "packages/daemon/src/features/memory/semantic-routes.ts": "9647fd48d291cb9b5eecb13bff452b91acb4e47ebbcd77ce8ff8cafe35818ab5", + "packages/daemon/src/features/memory/semantic-service.ts": "7f1f562eb4b72ac6ef5f1088760bd92ffae63a49349b913ba3dd0f86ebc3fc6d", + "packages/daemon/src/features/memory/wiki-compiler.ts": "7f3ef829e6e9f8ca7c705254ebec1426da01e80ec0983d036506b57c683172f3", + "packages/daemon/src/features/memory/wiki-routes.ts": "b3d01054a93cc9a15ed21c6fcac2eb90184444a7845e49df40a2c64e9cabba02", + "packages/daemon/src/features/skills/procedure-observation-service.ts": "45dc51fb51935e4c1b2717be76686f1928b2e0bb60a03610af0b62b80005bd55", + "packages/daemon/src/features/skills/revision-notices.ts": "78b536285a0e50c7c5a37cbd6d365f5e961121fc462d9b2070f55b6fe923ef5a", + "packages/core/src/daemon-shutdown-observer.ts": "97586c0f37672dd44ffb23e0506b457a4fa346ea9ad586be9452e7a72826fb32", + "packages/daemon/src/runtime/signal-shutdown.ts": "30fc8f139761e11462d11b84b2bb90da31c77fc84d126fe53ab6d472fb5a6821", + "packages/daemon/src/features/runs/workflow-command-cwd.ts": "1c73d349fa937aaec5e01bb78c9de0bb23f0ad56579bc220c2c852ade3828c2e", + "packages/daemon/src/runtime/sandbox/restricted-command-directory.ts": "38d8dd069e7f7f2e5630b40c0bf37580e5681c1dcf634ab480d8a98a1ee189a1", + "apps/desktop/main/desktop-runtime.ts": "dd43f9419f9c06c3d85ab0bf31220db394dd5f7d3a24a47ae0ca9bef03117f78", + "packages/core/src/daemon-resource-bootstrap.ts": "39549fe2e817e2e7fc3f1f311bd28a4443f42f973426c50ebc7a8d7f53428cb0", + "packages/core/src/run-final-settlement-types.ts": "9f8a17040fb75c830f0ede4b10e016dd46045bafe3baeaddf2ec92e7175891d7", + "packages/core/src/run-suspension-activation-types.ts": "37888fceda74ada434b19c8cc65126067a85af114097ec60204c6343e25833c1", + "packages/core/src/run-suspension-delegated-types.ts": "729497f1e7789096ee712727131de6478f1c2fdf3a957c993ca8ad0c67ac7f69", + "packages/core/src/run-suspension-start-types.ts": "bfd807bd0d83606e3a4641dad065cd8f07511ab05a213e9e7712da97cd4a896a", + "packages/core/src/run-suspension-stop-types.ts": "67f0aea943b5ec2e7420f2dee57a15aa8749b098086ff63615b620b5d30794ac", + "packages/core/src/run-suspension-types.ts": "4ce1f025c35ee0e35788cde7716c5d9a2da7ed61859348f531ebee39d96b88c0", + "packages/core/src/storage/sqlite/delegation-resume-lineage.ts": "ae13d5c212bae158c07bfab1d2df518eceb42130d0525dd1a0412cbe190a1e40", + "packages/core/src/storage/sqlite/run-compaction-suspension-effects.ts": "4df6e0923aa4b18889b0caf9b46521bbcf5339d9d8c70f49963427f815c5f3c2", + "packages/core/src/storage/sqlite/run-compaction-suspension-lineage.ts": "a9308f3dd06f0e8a3f35e99b783285e2d5672ed573eadeb72c464a5aef33a870", + "packages/core/src/storage/sqlite/run-compaction-suspension-witness.ts": "c3ecf42ede980caf287ff6f56d4e8269783ea8fdb2997707b91e8a163e38c4eb", + "packages/core/src/storage/sqlite/run-final-settlement-handoff.ts": "63fbb943c428fa3617516e7227ced3da8a4041bb34810ba06595a0bc204f164f", + "packages/core/src/storage/sqlite/run-final-settlement-migration.ts": "38f8755ca5105b8ef3e37cd1947cdd3049ae7458dc6bba6d66b03f41d50b71cc", + "packages/core/src/storage/sqlite/run-final-settlement-records.ts": "81f119dfb994105653ba571360b87477e3decae8893f54bac8f9edb02c82e78f", + "packages/core/src/storage/sqlite/run-final-settlement-store.ts": "500a682e22b66513fdd365b5b2a389d6ad26f96c5116ba4e1eeaf55f95f0c599", + "packages/core/src/storage/sqlite/run-suspension-activation-migration.ts": "fd0acae962e29b721a6d1753ebbbd202dce4f4a03b245a77ddd0bc845625ea3f", + "packages/core/src/storage/sqlite/run-suspension-activation-records.ts": "2a6c0901cf8f8d42510113582091238f81a7e48a15f2bc3d4f51284260f13d6e", + "packages/core/src/storage/sqlite/run-suspension-activation-store.ts": "65ced3ced00ae32a4b3fed2930c8306e2aad05f0ed676da36ffadb0bf86243e3", + "packages/core/src/storage/sqlite/run-suspension-delegated-migration.ts": "dbe5c2397e67f7f094e76ea5488ff564a6a914077fcced7bbb12e8756e4d5ed8", + "packages/core/src/storage/sqlite/run-suspension-delegated-records.ts": "cbee662a46365720c40a0857e0c1750cce6931e9741073639427e722075f07c7", + "packages/core/src/storage/sqlite/run-suspension-delegated-store.ts": "65fae49857360c274ad7e75af7b18439502c7a2f055eb2d867ab538e09bb59cf", + "packages/core/src/storage/sqlite/run-suspension-dependencies.ts": "6eb696c526efcb13b95e939eab2a19d1abafc8428e7c6b62a4782337720c9737", + "packages/core/src/storage/sqlite/run-suspension-migration.ts": "fd7e27b1575c456864c73a79985134dd561069d3fceea5815cd339097ac3dd7f", + "packages/core/src/storage/sqlite/run-suspension-queued-proof.ts": "bb733d05f0e2914accdd36120b2051e898a766c87cf38d4273cf1ddcb506fb73", + "packages/core/src/storage/sqlite/run-suspension-start-migration.ts": "b984435207335459b73c142e50d3219e9f055357e2c77822bb6b2865dcff962f", + "packages/core/src/storage/sqlite/run-suspension-start-records.ts": "cc645b92f417954d16be9e2d3c63d0d4dad706c718aed9ad33be555d7337ebf2", + "packages/core/src/storage/sqlite/run-suspension-start-store.ts": "9abb2076072ec58ddf9d8a92e1cf9831a132d9982b4ba3f12a291819bb5af3cf", + "packages/core/src/storage/sqlite/run-suspension-stop-migration.ts": "3187f7fee5ca250d50e8c983d421fe5381d685d247840a9cda8049ed4bfc0f2b", + "packages/core/src/storage/sqlite/run-suspension-stop-records.ts": "5fdec43a9ea0d75d44c8e838b3e895e87d38fba6050aab2e29a50bbc49de5a9c", + "packages/core/src/storage/sqlite/run-suspension-stop-store.ts": "a7aa2451487c0ad888b5cf0e700dc3e0a5342e4a97533e2781419e57bb2ef0e8", + "packages/core/src/storage/sqlite/run-suspension-stopped-handoff.ts": "46951a2097fe5a68aa8aedb99a20c30061b7f4174169a2a6e7cb353aec7bf875", + "packages/core/src/storage/sqlite/run-suspension-store.ts": "2b32cea641397cb1b260703333947366eae307efee982990536267ffeff902cf", + "packages/core/src/storage/sqlite/run-suspension-witness.ts": "32266e15b18bc4089429c0f453f0d9902e1280e20df3cccc8fe3b328760fca82", + "packages/daemon/src/features/harness/continuations/final-settlement-observation.ts": "5a996639b7d174079b56f0d39e68287026bea80bc2eac3abdbc2fc374b18acf8", + "packages/daemon/src/features/harness/continuations/root-suspension-dispatch.ts": "d8ff249fda09f7b6268bd6ddbf45a2e29e34ce925fdc32330d3fcbb200ba447b", + "packages/daemon/src/features/harness/continuations/run-compaction-suspension.ts": "f2d50d25ef220be6a4dcd06d4d0297098fbd4c527e91f995a8849ffdac51600a", + "packages/daemon/src/features/harness/continuations/run-graceful-suspension.ts": "c22bd718f22141db0f1be1b683df1ebdf1d939985adcae7e55cf1c84a2617d0c", + "packages/daemon/src/runtime/delegated-terminal-handoff.ts": "b4a89ccfeebdae0373597ddffe110be0263d512454ae2cb5fc1e4c2a9068cce5", + "packages/daemon/src/runtime/run-shutdown-coordinator.ts": "41e0509ae6645d71f84807b1e8144958aa4aebf6795c48fe5c3e83fe3488c2cd", + "packages/daemon/src/runtime/run-suspension-order.ts": "22425cb7e092e79d84f89f1a0a16468588520ce9e7d6cb5f1a87861e5c413e26", + "apps/desktop/scripts/stage-development-bundled-plugins.mjs": "01fe4dcad164b1ce71ab4fe4b46e7f191b1124288c579bc20c4bfff6368a9911", + "packages/daemon/src/features/plugins/bundled/cua-driver/.ling-plugin/plugin.json": "d51fd14fa8b26e429dffd6163bf6d275094f2c289c0bf5012556cdc380b4be8a", + "packages/daemon/src/features/plugins/bundled/cua-driver/mcp-server.json": "31bed9da0af349835a54f29e0b5bd7d984f57a1dfbfad197fbd9072d2a4033af", + "packages/daemon/src/features/plugins/bundled/cua-driver/README.md": "d4e82c2cd48704c8078269a0c4a74bf484d183afbbd7c7c814a2e4db70a5e765", + "apps/desktop/scripts/cua-driver-parent-lease-stream-writer.mjs": "7efd979d0ec1445d3e1c30f306c9a33e4f181b8062ec4adbc87bf0caa7bb90ee", + "packages/core/src/harness/delegation-authority.ts": "9b4663e28dadafbef1a243c747ff2a1af7b000243f0183bb2fb7a24943bb32d3", + "packages/core/src/run-terminal-intent-types.ts": "35f89624818e4dc93af27ce13a27bd8a67ddf203f777a486f8439c1d017a89e4", + "packages/core/src/runtime-resource-renewal.ts": "438b4f750aaf7b3e34e29d3e697a04bbab79985cda5576fbc5456ceaf2efc066", + "packages/core/src/storage/sqlite/channel-workflow-pre-approval-store.ts": "c5cbd8a87e07704db253fc5b01d099f8caabfeafa9ef10174bcf3c4352281c37", + "packages/core/src/storage/sqlite/conversation-empty-fork-migration.ts": "dd26f82df4e4535bc7cf509043fbbc8b53b9e10e721e9dfd747b8d384de5968d", + "packages/core/src/storage/sqlite/run-terminal-intent-migration.ts": "c8a3ce298c9cec031331732122d4fc854b82352c907c773a5cd49c51c374ca52", + "packages/core/src/storage/sqlite/run-terminal-intent-store.ts": "441d38107070dfd6b5818fd48e8b302a96a2750361a2654dfbe27936c3a74596", + "packages/core/src/storage/sqlite/user-input-answer-authority.ts": "7dc907987e80d741a4c21fd332f6ac2d05227f6f4cabeb911f6ce7b608e37744", + "packages/core/src/storage/sqlite/user-input-prestart-recovery-store.ts": "83966230502cc671bf46e97a6c227c6fe0a044c249f84585d5c07db3209c8bd5", + "packages/core/src/storage/sqlite/user-input-run-created-replay.ts": "f40843b50da8d714963ba478de529edf2002fb9e28e04b37097dcfc3f1923e6b", + "packages/core/src/workflow-pre-gate.ts": "cf8e6248ab7a42dd27baecb30ae5f0dd61508a570aa106d2f04469d0ab1c819f", + "packages/daemon/src/runtime/packaged-qa-harness-bootstrap.ts": "892f7615b88f77e4c51571e76102a82fbd79d4a33ad3abefc6368237aa5d2597", + "packages/daemon/src/features/channels/channel-branch-origin.ts": "5f5437490880df72fe7c33cca83babe65526891f1a0d76f0b1efee6700a8e7b8", + "packages/daemon/src/features/channels/channel-intent-diagnostics.ts": "af1d07cb665dbdba0dc3743d8a0ed5a36940713105f71aa2f18d317c83b98c4b", + "packages/daemon/src/features/channels/terminal-report-command-evidence.ts": "67bb07e01b772b178efe982dae3f3e1f43945507b359c3a5bf98e426e3fbee48", + "packages/daemon/src/features/channels/workflow-pre-approval.ts": "57447481701f601f183237f14bd2ae2cf9d6009332d67d7da780ada40f8acc59", + "packages/daemon/src/features/channels/workflow-step-prompt.ts": "bc9716eb2b38f53dca759d4f029a2fa3769de84b4ba88a4e5cbe23e729295749", + "packages/daemon/src/features/conversations/user-input-answer-route.ts": "0b31cddc645b71e6f0d45beb05664092d19222585f1c6e3ecacde44c6d8c7a75", + "packages/daemon/src/features/harness/evaluations/task-worker-composition.ts": "9a325423e4cbd79685d8a63a7e8c16998c59c90bf0ce7dcf660e94e00c8c4947", + "packages/daemon/src/features/harness/tool-execution/broker-command-result.ts": "da5109d94f88ebacbdb34acb999afb3c63d94848c6327dac67294cf495a70194", + "packages/daemon/src/features/mcp/host-stdio-read-buffer.ts": "d711ffd90e14bf90dcb17a9a26adf1773bd821b59c294a42514c8db5c4610219", + "packages/daemon/src/features/runs/approval-decision-projection.ts": "6559cdb3a26e1af02e4fde338094770dfb5f472e3d779440db37e705a64a133f", + "packages/daemon/src/features/runs/approval-postcommit-evidence.ts": "09270bb3c0da42aa140f2f7e14274a8d13dca153d5d37da1c4b8dbd510831d98", + "packages/daemon/src/features/runs/public-execution-snapshots.ts": "08e2a684e2e6bd336d56c2f9230e34dbb2c8eae559684d8546b854ecfe5a975f", + "packages/daemon/src/features/runs/workflow-step-input-turn.ts": "153516173138c1afa1ff4e84b584dca9a41088cb7887e203ff3c12167a61278c", + "packages/daemon/src/runtime/sandbox/external-workflow-request-runtime.ts": "71564c4329aba59bcbe41c3af6ee4a9dac0cec1fd4281c5c94df4f761bfbde60", + "packages/daemon/src/runtime/sandbox/external-workflow-request-source.ts": "f6f0ca1551d490a459e4cf67660b93c8090c334592986b464ab02d1c0aeaa865", + "packages/daemon/src/runtime/sandbox/external-workflow-request-worker-source.ts": "a48aceff51665c23610d3bd105f19b904f3982d16ef61be903a8c5f472ab96bf", + "packages/daemon/src/runtime/sandbox/node-command-inputs.ts": "d33cfbf0cbc0b0c384ad37f6c499e45ce3892ea0f5fe1bf5c84dd203e7df0be6", + "packages/daemon/src/runtime/workspace-runtime-owner.ts": "abf074ff79d69b43cbcd690301d04550442f4af99fd087af9c057890ed019469", + "packages/runner-pi/src/runtime-suspension.ts": "eddf0199f5603f7df42c568f54ddabf0221962e0b91fc428b306377ebeca15a1", + "packages/core/src/delegation-completion-barrier.ts": "bff6181e72b5930b38db5e2719146e881a96ddb39511e049346a9fadee812b68", + "packages/core/src/delegation-completion-evidence.ts": "6c6758fb7817c5e40760317d451d4e40912bc7c18b33f60a421d5359e8a1c5a9", + "packages/core/src/delegation-host-store-bridge.ts": "eb4cee8804af907ffe72caa658c4ebf29fd25aa4bd311aeeea6a07e18de93570", + "packages/core/src/harness/delegation-lifecycle-types.ts": "2c8351c10497ff9f512298c34a108fb8b5a92762a8611d3d53217db6016a9474", + "packages/core/src/harness/delegation-write-types.ts": "7fcd52a244ca004d4b9f94b2c562b9a5fbcc18c5b51a0d8ffb5a359957dca634", + "packages/core/src/harness/delegation-write-validation.ts": "507387264d58752cb9046b6759d809bf64bfdd6f3bc4530004a85fe29ac8491c", + "packages/core/src/harness/reliability.ts": "590b3a286847eacf8592c9c5e92958e5bb6be23063110223cec46308efbfeac2", + "packages/core/src/storage/sqlite/delegation-budget-migration.ts": "c8a1a2bd0f4f5487fa0bee1a145110f367da1ef97bf3212434e77dbe834878d3", + "packages/core/src/storage/sqlite/delegation-completion-evidence.ts": "f18f95bba3d31cd43ea6eecebd257c2a53a007502c917388bb26e4b00588fa10", + "packages/core/src/storage/sqlite/delegation-instruction-migration.ts": "2d117c1c6aab5bea211f796f7bcb3077f86c3b96d8f8b1d28d7914371dede3b4", + "packages/core/src/storage/sqlite/delegation-lifecycle-store.ts": "4bef111bbd40d7e31dcdcaf9bd15ff7edd1e4532f6e7c8ad92afcd09885a9c49", + "packages/core/src/storage/sqlite/delegation-stopped-handoff.ts": "8f1f201026a47b7fc0f559a9e10724ff70f2fc1c92f0522e1dbdf1a016ea7d74", + "packages/core/src/storage/sqlite/delegation-write-store.ts": "c71de980c2732de52ef3f2fadf7a5f03b3c9a12422242696950b22864035283c", + "packages/core/src/storage/sqlite/harness-artifact-retention-store.ts": "0419b997c67b5820c8b7e52bffdf27122e5142de50553af7a01a55451f9eade2", + "packages/core/src/storage/sqlite/harness-artifact-recovery-pins.ts": "feabaf5ad3cc6aff09fdde695518aa03b70cb7303b8b55bee0886d002e6ce627", + "packages/core/src/storage/sqlite/harness-projection-checkpoint-store.ts": "708608599a04c16d368ed7135056a670ecf04f4c645c60a77e8b1a90b80fdab0", + "packages/core/src/storage/sqlite/harness-reliability-migration.ts": "623e5ddc0fcf6e6959e0d103e3c0e6431c0df01fd270d3a5f92ee04f6cd54e91", + "packages/core/src/storage/sqlite/harness-retention-index-migration.ts": "69334a8e29a0883eadca718b8be72c09c3cc86786b1d9fbc9f1c102a4e545959", + "packages/core/src/storage/sqlite/harness-recovery-index-migration.ts": "00ca66fb4bd201a59535a5776ce44200779a1755e8ac8b2448e77250580d6166", + "packages/core/src/storage/sqlite/host-attempt-read-scope.ts": "a66f570782dfed076a9df3b9d27527fe1710d8db9b70fdaa993c73cc66055228", + "packages/core/src/storage/sqlite/run-ancestry.ts": "ef82d1faf827a67c17b35c221f6a493a77db1caf1c8174a346b18aec86f030d2", + "packages/daemon/src/features/channels/terminal-report-delegation-evidence.ts": "7cf447016456989b2c05dc30b0d17df569a92bba9993982c8bc0dea1fb1cce8c", + "packages/daemon/src/features/harness/delegations/completion-wait.ts": "3bc0e6b54d7a40ef3d73994c7d27682d3d7c362a760242d74b13022f99a9dc3e", + "packages/daemon/src/features/harness/delegations/delegated-run-dispatch.ts": "9a16e98c5503fc09bb61d6c62a2438a38cf8f1cbd72be86983668a57dd1a7894", + "packages/daemon/src/features/harness/delegations/delegated-run-recovery.ts": "e0865018e4fbf26f766b111e465ff05a41659891f347b7dbe913f10b08db25e4", + "packages/daemon/src/features/harness/delegations/provider-budget-host.ts": "319e15d99f82e27bd33f6d8cc09956ffa7e2c7b626904d63a3fc9612b5a63e29", + "packages/daemon/src/features/harness/delegations/runner-tools.ts": "16ebc01d564a5156d5cb82d445748bd0e20848485e50ba18ee485d49ca394e97", + "packages/daemon/src/features/harness/delegations/runtime-registry.ts": "07c91d7f72e9b719279461d3902b92c4d63d610c91999bc7034909fd7b2437bd", + "packages/daemon/src/features/harness/delegations/store-composition.ts": "08ad09cb59c459240e76f197fe6384056eb1eb22a9949c35f861b6a804fb6d18", + "packages/daemon/src/features/harness/evaluations/qa-model-pilot-runner.ts": "1d9c7c14c930af0e49d657ec5f2d08f32fd97c2a3743dd50d6f935e25d429505", + "packages/daemon/src/features/harness/reliability/artifact-retention-service.ts": "8a2532e3c26884957c4894a991cb213f634cb11ec7ab0390e8168cd06ce2e6ed", + "packages/daemon/src/features/harness/reliability/lifecycle-coordinator.ts": "950dd3a89d892e9c6b3687d455905ed56ec0ee137b7c84475665237a77519b9c", + "packages/daemon/src/features/harness/reliability/projection-reconciler.ts": "678fb000caa3002232dd27dd0af8ab0a3f74707b4fbc0291fd0623d1fae83c9a", + "packages/runner-pi/src/delegation-instruction.ts": "46b4d581e2017648f04fc18bf87f90ee1738d7db8579ad6fa1e82018dbc04647", + "packages/runner-pi/src/extensions/delegation.ts": "2ca0e9458e5930ca72dc6e97cdc1fae00d0630d293cb71e4da3ad4b91058f362", + "packages/runner-pi/src/provider-budget.ts": "12de69f1f6f022f272add79b989e9564b75a96dfa719525b9374e0d94f0fea72", + "packages/core/src/storage/sqlite/delegation-budget-rows.ts": "d8dff558da289323eb1a7d998cee0e901e110fa298fa0b0c31d7b647f9dc49d2", + "packages/core/src/storage/sqlite/harness-reliability-schema.ts": "9354fed75a556ae3e3ab1c8d16e8bc3084810e4e04f1fe87e5da19ba7cbb7d2e", + "packages/daemon/src/features/harness/delegations/host-authority.ts": "1f1e9b02b7fbfb6f30f87cf945a28e77447670f793c19cf7255cdb4498f80769", + "packages/daemon/src/features/harness/delegations/price-registry.ts": "2926f3cd888d7fe4e0b7f398576674e8b1d20dd4114025130d490ba534709d5f", + "packages/daemon/src/features/execution-engines/acp-sdk-version.ts": "1fbbd102c6e6764a30c3a0afd135ad4f573e5b21c5c13833bce50ccc6302535b" }, "headers": { "selector": "local_environment_path", diff --git a/package.json b/package.json index 2a30bedb7..c3342bd7e 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "i18n:compile": "corepack pnpm --filter @ling-stack/localization catalog:compile", "check:licenses": "node scripts/check-license-policy.mjs", "check:public": "node scripts/check-public-repo-safety.mjs", - "check:dependency-security": "corepack pnpm --filter @ling-stack/mobile exec node --test test/dependency-security-patches.test.mjs test/dependency-security-tooling.test.mjs && corepack pnpm --filter @ling-stack/daemon exec node --test test/dependency-security.test.mjs && node --test scripts/dependency-security-regressions.test.mjs", + "check:dependency-security": "corepack pnpm --filter @ling-stack/mobile exec node --test test/dependency-security-patches.test.mjs test/dependency-security-tooling.test.mjs && corepack pnpm --filter @ling-stack/daemon exec node --test test/dependency-security.test.mjs && node --test scripts/dependency-security-regressions.test.mjs scripts/dependency-security-minimatch.test.mjs scripts/dependency-security-glob.test.mjs", "typecheck": "corepack pnpm --workspace-concurrency=1 -r typecheck", "ling": "node packages/cli/dist/index.js", "local:restart": "node packages/cli/dist/index.js restart", @@ -56,7 +56,6 @@ ], "overrides": { "@radix-ui/react-dismissable-layer@1.1.19": "1.1.20", - "@expo/cli>zod": "4.6.6", "brace-expansion@>=1.0.0 <1.1.21": "1.1.21", "brace-expansion@>=2.0.0 <2.1.7": "2.1.7", "brace-expansion@>=5.0.0 <5.0.12": "5.0.12", @@ -81,12 +80,14 @@ }, "patchedDependencies": { "http-cache-semantics@4.3.0": "patches/http-cache-semantics@4.3.0.patch", - "node-forge@1.4.0": "patches/node-forge@1.4.0.patch", "braces@3.0.3": "patches/braces@3.0.3.patch", "sprintf-js@1.0.3": "patches/sprintf-js@1.0.3.patch", "sprintf-js@1.1.3": "patches/sprintf-js@1.1.3.patch", "image-size@2.0.4": "patches/image-size@2.0.4.patch", - "@expo/cli@58.1.13": "patches/@expo__cli@58.1.13.patch" + "minimatch@9.0.9": "patches/minimatch@9.0.9.patch", + "minimatch@10.2.6": "patches/minimatch@10.2.6.patch", + "glob@11.1.0": "patches/glob@11.1.0.patch", + "glob@13.0.6": "patches/glob@13.0.6.patch" } } } diff --git a/packages/daemon/src/features/execution-engines/acp-sdk-version.ts b/packages/daemon/src/features/execution-engines/acp-sdk-version.ts new file mode 100644 index 000000000..6210f93df --- /dev/null +++ b/packages/daemon/src/features/execution-engines/acp-sdk-version.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageName = "@agentclientprotocol/sdk"; +// Only the Desktop bundler replaces this literal from its resolved SDK manifest. +const embeddedBundledAcpSdkVersion: string = "NO_PACKAGED_ACP_SDK_VERSION"; + +/** Follow the installed entry because SDK releases can change their dist layout. */ +export function readInstalledAcpSdkVersion(entryUrl?: string): string { + if (embeddedBundledAcpSdkVersion !== "NO_PACKAGED_ACP_SDK_VERSION") return embeddedBundledAcpSdkVersion; + entryUrl ??= import.meta.resolve(packageName); + let directory = dirname(fileURLToPath(entryUrl)); + while (true) { + let manifest: { name?: unknown; version?: unknown } | undefined; + try { + manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (manifest?.name === packageName) { + if (typeof manifest.version !== "string" || manifest.version.trim().length === 0) { + throw new Error(`Invalid ${packageName} version in ${directory}`); + } + return manifest.version; + } + const parent = dirname(directory); + if (parent === directory) throw new Error(`Unable to find ${packageName} manifest for ${entryUrl}`); + directory = parent; + } +} diff --git a/packages/daemon/src/features/execution-engines/native-admission.ts b/packages/daemon/src/features/execution-engines/native-admission.ts index f235e7bd2..793f9c59c 100644 --- a/packages/daemon/src/features/execution-engines/native-admission.ts +++ b/packages/daemon/src/features/execution-engines/native-admission.ts @@ -5,6 +5,7 @@ import { HttpError } from "../../errors.js"; import { assessRunnerAcceptance } from "../../runtime/runner-acceptance.js"; import { runnerAcceptanceFingerprint } from "../../runtime/runner-acceptance-store.js"; import type { NativeRunAdmissionInput } from "../runs/native-run-dispatcher.js"; +import { readInstalledAcpSdkVersion } from "./acp-sdk-version.js"; export interface NativeAdmissionOptions { /** Trusted host composition only; never read these callbacks from a request or workspace config. */ @@ -29,7 +30,7 @@ export async function assertNativeExecutionAccepted(options: NativeAdmissionOpti : thread?.kind === "automation" ? "automation" : thread?.kind === "external_work" ? "external_work" : input.runRequest.task.channelId ? "channel" : input.workflowSurface ?? "task"; return { runnerId: binding.engineId, runtimeVersion: installation.runtimeVersion ?? "", - sdkVersion: binding.engineId === "codex-app-server" ? "codex-app-server-schema-v2" : "@agentclientprotocol/sdk@1.9.0", + sdkVersion: binding.engineId === "codex-app-server" ? "codex-app-server-schema-v2" : `@agentclientprotocol/sdk@${readInstalledAcpSdkVersion()}`, adapterVersion: `${binding.engineId}:native-adapter-v1`, executableDigest: installation.executableDigest ?? "", platform: process.platform, architecture: process.arch, profileId: profile.id, profileRevision: profile.revision, configurationFingerprint: runnerAcceptanceFingerprint({ profile: profile.configurationFingerprint, binding }), diff --git a/packages/daemon/src/features/mcp/host-stdio-read-buffer.ts b/packages/daemon/src/features/mcp/host-stdio-read-buffer.ts new file mode 100644 index 000000000..d93b7e982 --- /dev/null +++ b/packages/daemon/src/features/mcp/host-stdio-read-buffer.ts @@ -0,0 +1,61 @@ +import { deserializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; + +/** Bounded SDK framing without copying and rescanning the entire partial frame on every chunk. */ +export class BoundedMcpStdioReadBuffer { + readonly #maxBufferSize: number; + #buffer?: Buffer; + #start = 0; + #end = 0; + #scan = 0; + + constructor(options: { readonly maxBufferSize: number }) { + this.#maxBufferSize = options.maxBufferSize; + } + + append(chunk: Buffer): void { + const pendingBytes = this.#end - this.#start; + const size = pendingBytes + chunk.length; + if (size > this.#maxBufferSize) { + this.clear(); + throw new Error("MCP stdio read buffer exceeded its byte limit."); + } + if (chunk.length === 0) return; + if (!this.#buffer || this.#buffer.length - this.#end < chunk.length) { + const previous = this.#buffer; + if (previous && size <= previous.length) { + previous.copy(previous, 0, this.#start, this.#end); + } else { + const capacity = Math.min(this.#maxBufferSize, Math.max(size, (previous?.length ?? 0) * 2)); + this.#buffer = Buffer.allocUnsafe(capacity); + previous?.copy(this.#buffer, 0, this.#start, this.#end); + } + this.#scan -= this.#start; + this.#start = 0; + this.#end = pendingBytes; + } + chunk.copy(this.#buffer!, this.#end); + this.#end += chunk.length; + } + + readMessage(): JSONRPCMessage | null { + if (!this.#buffer) return null; + // Search only received, previously unscanned bytes, never unused capacity. + const offset = this.#buffer.subarray(this.#scan, this.#end).indexOf(0x0a); + if (offset === -1) { + this.#scan = this.#end; + return null; + } + const newline = this.#scan + offset; + const line = this.#buffer.toString("utf8", this.#start, newline).replace(/\r$/u, ""); + this.#start = newline + 1; + this.#scan = this.#start; + if (this.#start === this.#end) this.clear(); + return deserializeMessage(line); + } + + clear(): void { + this.#buffer = undefined; + this.#start = this.#end = this.#scan = 0; + } +} diff --git a/packages/daemon/src/features/mcp/host-stdio-transport.ts b/packages/daemon/src/features/mcp/host-stdio-transport.ts index 9622b74c5..d1af30219 100644 --- a/packages/daemon/src/features/mcp/host-stdio-transport.ts +++ b/packages/daemon/src/features/mcp/host-stdio-transport.ts @@ -1,10 +1,11 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { Socket } from "node:net"; import { getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { ReadBuffer, serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; +import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js"; import type { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import { assertHostMcpOperation, McpHostAdmissionError, type HostMcpOperation } from "./host-operation.js"; +import { BoundedMcpStdioReadBuffer } from "./host-stdio-read-buffer.js"; import { MCP_STDIO_MAX_BUFFER_BYTES, type McpStdioLaunchInput } from "./stdio-launcher.js"; import { defineProtectedHostActorEntry } from "../../runtime/sandbox/protected-host-actor-inventory.js"; @@ -68,7 +69,7 @@ class OwnedStdioTransport implements HostMcpStdioTransport { readonly #operation: HostMcpStdioOperation; readonly #signal?: AbortSignal; readonly #sendTimeoutMs: number; - readonly #readBuffer = new ReadBuffer({ maxBufferSize: MCP_STDIO_MAX_BUFFER_BYTES }); + readonly #readBuffer = new BoundedMcpStdioReadBuffer({ maxBufferSize: MCP_STDIO_MAX_BUFFER_BYTES }); readonly #ready = deferred(); readonly #closed = deferred(); readonly #pendingSends = new Set<(error: Error) => void>(); diff --git a/packages/daemon/test/channel-discussion.test.mjs b/packages/daemon/test/channel-discussion.test.mjs new file mode 100644 index 000000000..888c6cf39 --- /dev/null +++ b/packages/daemon/test/channel-discussion.test.mjs @@ -0,0 +1,4200 @@ +import { appendChannelItemFixture, getChannelItemViewFixture } from "../../core/test/channel-conversation-fixture.mjs"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { DEFAULT_WORKSPACE_ID, createCompositeSecretResolver, createSqliteRunStore } from "../../core/dist/index.js"; +import { buildContextPack, estimateTextTokens, resolveProviderContextBudget } from "../dist/features/channels/context-packs.js"; +import { resolveChannelReplyExecutionTarget } from "../dist/features/channels/channel-reply-execution-target.js"; +import { planConversationContextSummary } from "../dist/features/channels/context-processing/conversation-summary-coordinator.js"; +import { buildLingAgentSessionViewForChannel } from "../dist/features/channels/ling-agent-session-view.js"; +import { buildLingAgentTurnEnvelope } from "../dist/features/channels/ling-agent-turn-envelope.js"; +import { findActiveChannelReplyRun } from "../dist/features/channels/channel-reply-dispatch.js"; +import { availabilityIncidentInputForProviderRuntimeFailure } from "../dist/features/channels/provider-runtime-observations.js"; +import { linkBodyExtractorV1 } from "../dist/features/channels/materialization/extractors/link-body.js"; +import { officeExtractorV1 } from "../dist/features/channels/materialization/extractors/office.js"; +import { pdfExtractorV1 } from "../dist/features/channels/materialization/extractors/pdf.js"; +import { attachmentMaterializationFingerprint, linkPreviewMaterializationFingerprint } from "../dist/features/channels/materialization/fingerprints.js"; +import { materializeInlineChannelInputs } from "../dist/features/channels/materialization/inline.js"; +import { OfficeArtifactService } from "../dist/features/office-documents/artifacts.js"; +import { materializeInlineTeammateDirectInputs } from "../dist/features/teammates/direct-materialization.js"; +import { ensureGitRepo } from "./git-test-helpers.mjs"; +import { startMockOpenAICompatibleServer } from "./support/mock-openai-compatible-server.mjs"; +import { + readPdfFixtureBytes, + readDocxFixtureBytes, + SummaryAwareRunner, + RecordingRunner, + PromptEchoRunner, + ScriptedRunner, + AbortableDelayedRunner, + readCurrentChannelUserMessage, + startDaemonForTest, + createStoreChannelAndMessage, + createActiveChannelReplyRunFixture, + createChannel, + setWorkspaceDefaultProvider, + createStaticChatProvider, + parseSseEvents, + getJson, + assertCoordinatorWorkflowStartReply, + assertSingleReplyIntentDecision, + waitForChannelMessageStatus, + waitForCondition, + postJson, + putJson, + patchJson, + deleteJson, +} from "./support/channel-workspace-fixture.mjs"; + +test("health exposes channel auto_route capability", async () => { + const disabledDaemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: false, intentSelector: false, teamObserverBus: false } }); + try { + const health = await getJson(`${disabledDaemon.url}/health`); + assert.equal(health.features.channels.autoRoute, false); + assert.equal(health.features.channels.intentSelector, false); + assert.equal(health.features.channels.teamObserverBus, false); + } finally { + await disabledDaemon.close(); + } + + const enabledDaemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: true, intentSelector: true, teamObserverBus: true } }); + try { + const health = await getJson(`${enabledDaemon.url}/health`); + assert.equal(health.features.channels.autoRoute, true); + assert.equal(health.features.channels.intentSelector, true); + assert.equal(health.features.channels.teamObserverBus, true); + } finally { + await enabledDaemon.close(); + } +}); + +test("channel runtime policy rejects removed executionMode field", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-removed-execution-mode-")); + const daemon = await startDaemonForTest(); + try { + const response = await fetch(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "Old runtime policy", + status: "active", + boundPath: cwd, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 2, + executionMode: "parallel", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + }), + }); + assert.equal(response.status, 400); + const body = await response.json(); + assert.match(body.message, /executionMode has been removed/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("provider runtime availability mapper records transient failures only", () => { + const providerPreset = { + id: "provider_static", + kind: "glm", + displayName: "Static Chat", + providerId: "static-chat", + model: "static-model", + api: "google-generative-ai", + }; + + const quota = availabilityIncidentInputForProviderRuntimeFailure({ + providerPreset, + code: "rate_limited", + message: "Too many requests", + now: "2026-06-27T00:00:00.000Z", + }); + assert.equal(quota?.kind, "quota"); + assert.equal(quota?.state, "rate_limited"); + assert.equal(quota?.providerPresetId, "provider_static"); + assert.equal(quota?.modelSignature?.model, "static-model"); + assert.equal(quota?.expiresAt, "2026-06-27T00:20:00.000Z"); + + const network = availabilityIncidentInputForProviderRuntimeFailure({ + providerPreset, + error: Object.assign(new Error("fetch failed: EAI_AGAIN"), { code: "network_failed" }), + now: "2026-06-27T00:00:00.000Z", + }); + assert.equal(network?.kind, "network"); + assert.equal(network?.state, "offline"); + + const promptLeak = availabilityIncidentInputForProviderRuntimeFailure({ + providerPreset, + error: new Error("Runner echoed internal channel context instead of an assistant reply."), + now: "2026-06-27T00:00:00.000Z", + }); + assert.equal(promptLeak, undefined); +}); + +test("channel proposal confirmation claim is durable, first-writer-wins, and recoverable", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-cas-")); + const store = createSqliteRunStore(cwd); + const now = "2026-09-19T10:00:00.000Z"; + try { + const { channel, message } = createStoreChannelAndMessage(store, cwd, "prepare a safe change"); + const proposal = store.createChannelExecutionProposal({ + channelId: channel.id, + sourceMessageId: message.id, + proposedByTeammateId: "ling_agent", + title: "Safe change", + prompt: "Prepare a safe change", + assigneeTeammateId: "ling_agent", + status: "draft", + }, now); + assert.equal(proposal.revision, 1); + + const first = store.claimChannelExecutionProposal( + proposal.id, + "proposal-confirm:first", + now, + "2026-09-19T10:05:00.000Z", + ); + assert.equal(first.kind, "claimed"); + assert.equal(first.proposal.revision, 2); + + const second = store.claimChannelExecutionProposal( + proposal.id, + "proposal-confirm:second", + now, + "2026-09-19T10:05:00.000Z", + ); + assert.equal(second.kind, "busy"); + + assert.equal(store.updateChannelExecutionProposal(proposal.id, { + status: "cancelled", + }, now), undefined, "a stale UI cancel cannot steal an active confirmation claim"); + assert.equal(store.updateChannelExecutionProposal(proposal.id, { + status: "blocked", + expectedRevision: 1, + }, now), undefined, "an old revision cannot overwrite a claimed proposal"); + + const blocked = store.updateChannelExecutionProposal(proposal.id, { + status: "blocked", + errorMessage: "provider unavailable", + expectedRevision: first.proposal.revision, + confirmationClaimId: "proposal-confirm:first", + }, now); + assert.equal(blocked?.status, "blocked"); + assert.equal(blocked?.revision, 3); + + const recovered = store.claimChannelExecutionProposal( + proposal.id, + "proposal-confirm:recovery", + "2026-09-19T10:06:00.000Z", + "2026-09-19T10:11:00.000Z", + ); + assert.equal(recovered.kind, "claimed", "blocked proposals can be retried after the prior claim is released"); + assert.equal(recovered.proposal.revision, 4); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("an expired proposal confirmation lease can be reclaimed without releasing the old claim", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-lease-expiry-")); + const store = createSqliteRunStore(cwd); + try { + const now = "2026-09-19T11:00:00.000Z"; + const { channel, message } = createStoreChannelAndMessage(store, cwd, "recover an abandoned confirmation"); + const proposal = store.createChannelExecutionProposal({ + channelId: channel.id, + sourceMessageId: message.id, + proposedByTeammateId: "ling_agent", + title: "Abandoned confirmation", + prompt: "Recover an abandoned confirmation", + assigneeTeammateId: "ling_agent", + status: "draft", + }, now); + + const first = store.claimChannelExecutionProposal( + proposal.id, + "proposal-confirm:abandoned", + now, + "2026-09-19T11:05:00.000Z", + ); + assert.equal(first.kind, "claimed"); + assert.equal(first.proposal.revision, 2); + + const recovered = store.claimChannelExecutionProposal( + proposal.id, + "proposal-confirm:recovered", + "2026-09-19T11:06:00.000Z", + "2026-09-19T11:11:00.000Z", + ); + assert.equal(recovered.kind, "claimed"); + assert.equal(recovered.proposal.revision, 3); + + assert.equal(store.updateChannelExecutionProposal(proposal.id, { + status: "blocked", + errorMessage: "The expired owner cannot finalize this proposal.", + expectedRevision: first.proposal.revision, + confirmationClaimId: "proposal-confirm:abandoned", + }, "2026-09-19T11:06:00.000Z"), undefined); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel branches follow archive restore and delete lifecycle", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-branches-")); + const stores = []; + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.push(store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const store = stores.find((item) => item.paths.cwd === cwd); + assert.ok(store); + const branch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { + name: "Model routing fix", + })).branch; + assert.ok(branch.rootThreadId); + const branchThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; + assert.equal(branchThread.kind, "channel_discussion"); + assert.equal(branchThread.channelId, channel.id); + assert.equal(branchThread.title, "Model routing fix"); + + const secondBranch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { + name: "Prompt cleanup", + })).branch; + assert.ok(secondBranch.rootThreadId); + assert.notEqual(secondBranch.rootThreadId, branch.rootThreadId); + appendChannelItemFixture(store, { + channelId: channel.id, + threadId: branch.rootThreadId, + authorType: "local-user", + body: "Message in model routing branch", + }, "2026-06-22T00:01:00.000Z"); + appendChannelItemFixture(store, { + channelId: channel.id, + threadId: secondBranch.rootThreadId, + authorType: "local-user", + body: "Message in prompt cleanup branch", + }, "2026-06-22T00:02:00.000Z"); + const firstBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(branch.rootThreadId)}`); + assert.deepEqual( + firstBranchMessages.messages + .filter((message) => message.authorType === "local-user") + .map((message) => message.body), + ["Message in model routing branch"], + ); + const secondBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(secondBranch.rootThreadId)}`); + assert.deepEqual( + secondBranchMessages.messages + .filter((message) => message.authorType === "local-user") + .map((message) => message.body), + ["Message in prompt cleanup branch"], + ); + + const legacyBranch = store.createChannelBranch({ + channelId: channel.id, + name: "Legacy branch", + }, "2026-06-22T00:00:00.000Z"); + assert.equal(legacyBranch.rootThreadId, undefined); + const repairedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`); + const repairedLegacy = repairedBranches.branches.find((item) => item.id === legacyBranch.id); + assert.equal(repairedLegacy?.rootThreadId, undefined); + assert.equal(store.getChannelBranch(legacyBranch.id).rootThreadId, undefined); + const explicitRepair = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(legacyBranch.id)}/repair?cwd=${encodeURIComponent(cwd)}`, {}); + assert.ok(explicitRepair.branch.rootThreadId); + assert.equal(store.getChannelBranch(legacyBranch.id).rootThreadId, explicitRepair.branch.rootThreadId); + + let listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listed.channels.some((item) => item.id === channel.id), true); + assert.equal(listed.branches.some((item) => item.id === branch.id), true); + + const archivedBranch = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "archived", + })).branch; + assert.equal(archivedBranch.status, "archived"); + assert.ok(archivedBranch.archivedAt); + const archivedThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; + assert.ok(archivedThread.archivedAt); + assert.equal(archivedThread.archivedReason, "channel_branch_archived"); + + listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listed.branches.some((item) => item.id === branch.id), false); + + let archivedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}&archived=true`); + assert.equal(archivedBranches.branches.some((item) => item.id === branch.id), true); + + const restoredBranch = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "active", + })).branch; + assert.equal(restoredBranch.status, "active"); + assert.equal(restoredBranch.archivedAt, undefined); + const restoredThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; + assert.equal(restoredThread.archivedAt, undefined); + assert.equal(restoredThread.archivedReason, undefined); + + const activeDelete = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "DELETE", + }); + assert.equal(activeDelete.status, 409); + + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "archived", + }); + await deleteJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`); + const deletedBranchThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; + assert.equal(deletedBranchThread.id, branch.rootThreadId); + assert.ok(deletedBranchThread.archivedAt); + archivedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}&archived=true`); + assert.deepEqual(archivedBranches.branches, []); + + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "archived", + }); + listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listed.channels.some((item) => item.id === channel.id), false); + + const archivedChannels = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}&archived=true`); + assert.deepEqual(archivedChannels.channels.map((item) => item.id), [channel.id]); + + const restoredChannel = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "active", + })).channel; + assert.equal(restoredChannel.status, "active"); + listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listed.channels.some((item) => item.id === channel.id), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("context budget manager resolves provider modes and trims lower priority sections", () => { + const providerBase = { + id: "provider_context_test", + kind: "openai", + displayName: "Context Test", + providerId: "openai", + model: "context-model", + api: "openai-responses", + headers: {}, + input: ["text"], + compat: {}, + capabilities: ["chat"], + enabled: true, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + }; + + const known = resolveProviderContextBudget({ ...providerBase, contextWindow: 12000, maxTokens: 2000 }, "channel_reply"); + assert.equal(known.mode, "model_known"); + assert.equal(known.contextWindowTokens, 12000); + assert.equal(known.reservedOutputTokens, 2000); + assert.equal(known.inputBudgetTokens, 9488); + assert.equal(known.capabilitySource, "user_override"); + assert.equal(known.capabilityConfidence, "known"); + + const estimated = resolveProviderContextBudget(providerBase, "intent_selector"); + assert.equal(estimated.mode, "model_estimated"); + assert.equal(estimated.contextWindowTokens, 32768); + assert.equal(estimated.reservedOutputTokens, 512); + assert.equal(estimated.capabilitySource, "provider_metadata"); + assert.equal(estimated.capabilityConfidence, "estimated"); + + const unknown = resolveProviderContextBudget({ ...providerBase, kind: "openai-compatible" }, "channel_reply"); + assert.equal(unknown.mode, "model_unknown"); + assert.equal(unknown.contextWindowTokens, 8192); + assert.equal(unknown.capabilitySource, "conservative_default"); + assert.equal(unknown.capabilityConfidence, "conservative"); + + const trimmed = buildContextPack({ + kind: "channel_reply", + providerPreset: { ...providerBase, contextWindow: 1800, maxTokens: 512 }, + sections: [ + { id: "must", role: "user", priority: "must_keep", text: "must", estimatedTokens: 900 }, + { id: "high", role: "evidence", priority: "high", text: "high", estimatedTokens: 900 }, + { id: "low", role: "evidence", priority: "low", text: "low", estimatedTokens: 900 }, + ], + }); + assert.deepEqual(trimmed.sections.map((section) => section.id), ["must"]); + assert.deepEqual(trimmed.droppedSectionIds, ["low", "high"]); + assert.equal(trimmed.overBudget, true); + + const protectedOnly = buildContextPack({ + kind: "channel_reply", + providerPreset: { ...providerBase, contextWindow: 1800, maxTokens: 512 }, + sections: [{ id: "current_user_turn", role: "user", priority: "must_keep", text: "full current request", estimatedTokens: 3_000 }], + }); + assert.deepEqual(protectedOnly.sections.map((section) => section.id), ["current_user_turn"]); + assert.deepEqual(protectedOnly.droppedSectionIds, []); + assert.equal(protectedOnly.overBudget, true); +}); + +test("ling agent session view is open when channel has no active work", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-open-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "hello"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.id, `ling-agent-session:${channel.id}`); + assert.equal(view.status, "open"); + assert.equal(view.owner, "ling_agent"); + assert.equal(view.confirmation, undefined); + assert.equal(view.progress, undefined); + assert.equal(view.finalResult, undefined); + assert.deepEqual(view.pendingApprovalIds, []); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view projects draft proposal as task confirmation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-proposal-")); + const store = createSqliteRunStore(cwd); + try { + const { channel, message } = createStoreChannelAndMessage(store, cwd, `在 ${cwd} 下创建 README.md`); + const proposal = store.createChannelExecutionProposal({ + channelId: channel.id, + sourceMessageId: message.id, + proposedByTeammateId: "ling_agent", + title: "Create README.md", + prompt: `Target: ${cwd}/README.md`, + assigneeTeammateId: "ling_agent", + requiredTools: ["write"], + riskSummary: "Need write permission for README.md.", + status: "draft", + }, "2026-06-11T00:01:00.000Z"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "ready_for_confirmation"); + assert.equal(view.objective, proposal.title); + assert.equal(view.confirmation?.proposalId, proposal.id); + assert.deepEqual(view.confirmation?.requiredTools, ["write"]); + assert.equal(view.confirmation?.assigneeTeammateId, "ling_agent"); + assert.ok(view.evidenceRefs.some((ref) => ref.type === "proposal" && ref.id === proposal.id)); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("proposed plan APIs hydrate deleted source message state without deleting plans", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-proposed-plan-source-state-")); + const store = createSqliteRunStore(cwd); + let thread; + let channel; + let message; + try { + ({ channel, message } = createStoreChannelAndMessage(store, cwd, "source plan")); + thread = store.createLingThread({ + kind: "channel", + channelId: channel.id, + title: "Plan source state", + objective: "Hydrate proposed plan source state.", + }, "2026-06-11T00:01:00.000Z"); + store.close(); + + const daemon = await startDaemonForTest(); + try { + const available = await postJson(`${daemon.url}/threads/${encodeURIComponent(thread.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}`, { + id: "plan_available_source", + channelId: channel.id, + sourceMessageId: message.id, + title: "Available source plan", + bodyMarkdown: "Use the source message.", + }); + assert.equal(available.plan.sourceMessageState, "available"); + + const orphan = await postJson(`${daemon.url}/threads/${encodeURIComponent(thread.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}`, { + id: "plan_deleted_source", + channelId: channel.id, + sourceMessageId: "msg_deleted_source", + title: "Deleted source plan", + bodyMarkdown: "Keep this plan for audit history.", + }); + assert.equal(orphan.plan.sourceMessageState, "deleted"); + + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}&limit=20`); + const listedOrphan = listed.plans.find((plan) => plan.id === "plan_deleted_source"); + assert.equal(listedOrphan?.sourceMessageState, "deleted"); + assert.equal(listed.plans.some((plan) => plan.id === "plan_available_source" && plan.sourceMessageState === "available"), true); + } finally { + await daemon.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel team metrics exposes visible evidence total separately from team evidence", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-evidence-summary-")); + const store = createSqliteRunStore(cwd); + try { + const { channel, message } = createStoreChannelAndMessage(store, cwd, "collect evidence"); + const webMessage = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "ai-teammate", + authorId: "ling_agent", + body: "web evidence", + fileRefs: [{ path: `${cwd}/README.md`, line: 12 }], + }, "2026-06-11T00:02:00.000Z"); + const workUnit = store.createWorkUnit({ + id: "wu_channel_web_evidence", + workspaceId: DEFAULT_WORKSPACE_ID, + kind: "channel_ai_reply", + ownerType: "channel_message", + ownerId: webMessage.id, + channelId: channel.id, + idempotencyKey: "wu-channel-web-evidence", + createdByActor: "system", + request: { messageId: webMessage.id }, + linkedMessageId: webMessage.id, + now: "2026-06-11T00:02:05.000Z", + }); + store.appendRuntimeEvent({ + workUnitId: workUnit.id, + type: "web_evidence_recorded", + actor: "system", + ts: "2026-06-11T00:02:10.000Z", + data: { + toolSurface: "web", + sourceMessageId: message.id, + linkedMessageId: webMessage.id, + sourceCount: 1, + readUrlCount: 1, + status: "degraded", + query: "ling stack", + sources: [{ url: "https://example.com/source", title: "Example source" }], + readUrls: [{ url: "https://example.com/read", status: "blocked", reason: "timeout" }], + updatedAt: "2026-06-11T00:02:10.000Z", + }, + }); + const task = store.createTask({ + channelId: channel.id, + title: "Evidence task", + prompt: "Collect visible evidence.", + }, "2026-06-11T00:03:00.000Z"); + const run = store.createRun({ + id: "run_channel_visible_evidence", + cwd, + taskId: task.id, + channelId: channel.id, + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: task.id, title: task.title }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-11T00:04:00.000Z", + }); + store.createApprovalRequest({ + runId: run.id, + taskId: task.id, + channelId: channel.id, + actionType: "bash", + risk: "medium", + title: "Approve command", + description: "Approve visible evidence command.", + payload: { type: "bash", cwd, command: "echo ok", riskFlags: [] }, + }, "2026-06-11T00:04:30.000Z"); + store.createArtifact({ + runId: run.id, + type: "file", + path: `${cwd}/report.md`, + title: "report.md", + createdAt: "2026-06-11T00:05:00.000Z", + }); + store.createChannelAttachment({ + channelId: channel.id, + messageId: webMessage.id, + kind: "text", + fileName: "notes.txt", + contentType: "text/plain", + sizeBytes: 20, + path: `${cwd}/notes.txt`, + createdAt: "2026-06-11T00:05:30.000Z", + }); + for (let index = 0; index < 25; index += 1) { + store.createChannelAttachment({ + channelId: channel.id, + messageId: webMessage.id, + kind: "text", + fileName: `extra-${index}.txt`, + contentType: "text/plain", + sizeBytes: 20 + index, + path: `${cwd}/extra-${index}.txt`, + createdAt: `2026-06-11T00:05:${String(31 + index).padStart(2, "0")}.000Z`, + }); + } + store.createChannelTeamEvidenceItem({ + channelId: channel.id, + turnId: webMessage.id, + agentId: "engineer", + kind: "specialist_note", + evidenceGrade: "observed", + summary: "Team-only specialist evidence.", + visibility: "internal", + status: "completed", + }, "2026-06-11T00:06:00.000Z"); + store.close(); + + const daemon = await startDaemonForTest(); + try { + const response = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/team-metrics?cwd=${encodeURIComponent(cwd)}&limit=50`); + assert.equal(response.metrics.summary.totalEvidenceCount, 1); + assert.equal(response.metrics.visibleEvidence.totalCount, 24); + assert.deepEqual(response.metrics.visibleEvidence.countsByKind, { + runs: 1, + approvals: 1, + browser: 2, + files: 20, + team_evidence: 1, + }); + } finally { + await daemon.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("approval-only workflow owner closes immediately after approval", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-approval-owner-")); + const store = createSqliteRunStore(cwd); + let workflow; + let step; + let approval; + try { + const channel = store.createChannel({ name: "Approval owner", boundPath: cwd }, "2026-06-11T00:00:00.000Z"); + workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Review risky command", + objective: "Close the approval-only workflow owner.", + status: "waiting_for_approval", + createdBy: "system", + }, "2026-06-11T00:01:00.000Z"); + step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + title: "Review risky shell command", + prompt: "Review the proposed command.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + status: "waiting_for_approval", + }, "2026-06-11T00:01:10.000Z"); + approval = store.createApprovalRequest({ + workflowStepId: step.id, + channelId: channel.id, + actionType: "bash", + risk: "high", + title: "Review risky shell command", + description: "Approval-only legacy workflow step.", + payload: { type: "bash", cwd, command: "git status --short", riskFlags: ["requires_user_review"] }, + }, "2026-06-11T00:01:20.000Z"); + store.close(); + + const daemon = await startDaemonForTest(); + try { + const resolved = await postJson(`${daemon.url}/approvals/${encodeURIComponent(approval.id)}/approve?cwd=${encodeURIComponent(cwd)}`, { + reason: "Reviewed.", + }); + assert.equal(resolved.approval.status, "approved"); + + const workflowResponse = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflowResponse.workflow.status, "succeeded"); + const stepsResponse = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + assert.equal(stepsResponse.workflowSteps.find((candidate) => candidate.id === step.id)?.status, "succeeded"); + } finally { + await daemon.close(); + } + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view projects active workflow approval wait", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-approval-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "帮我跑测试"); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Run tests", + objective: "Run the test suite.", + status: "running", + createdBy: "ling_agent", + }, "2026-06-11T00:01:00.000Z"); + const step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + title: "Execute test command", + prompt: "Run tests.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + status: "waiting_for_approval", + }, "2026-06-11T00:02:00.000Z"); + const run = store.createRun({ + id: "run_ling_agent_session_waiting", + cwd, + taskId: "task_ling_agent_session_waiting", + channelId: channel.id, + teammateId: "ling_agent", + workflowStepId: step.id, + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_ling_agent_session_waiting", title: "Execute test command" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-11T00:03:00.000Z", + }); + store.markRunWaitingForApproval(run.id, "2026-06-11T00:04:00.000Z"); + store.updateChannelWorkflowStep(step.id, { runId: run.id }, "2026-06-11T00:04:00.000Z"); + const approval = store.createApprovalRequest({ + runId: run.id, + taskId: run.taskId, + channelId: channel.id, + actionType: "bash", + risk: "medium", + title: "Approve test command", + description: "Run tests.", + payload: { type: "bash", cwd, command: "pnpm test", riskFlags: [] }, + expiresAt: "2999-01-01T00:00:00.000Z", + }, "2026-06-11T00:05:00.000Z"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "waiting_for_approval"); + assert.equal(view.activeWorkflowId, workflow.id); + assert.equal(view.activeRunId, run.id); + assert.deepEqual(view.pendingApprovalIds, [approval.id]); + assert.equal(view.progress?.workflowId, workflow.id); + assert.equal(view.progress?.activeStepId, step.id); + assert.ok(view.evidenceRefs.some((ref) => ref.type === "approval" && ref.id === approval.id)); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view projects terminal workflow final result", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-final-workflow-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "实现并验证"); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Implement feature", + objective: "Implement and verify the feature.", + status: "succeeded", + createdBy: "ling_agent", + }, "2026-06-11T00:01:00.000Z"); + const firstStep = store.createChannelWorkflowStep({ + workflowId: workflow.id, + position: 1, + title: "Implementation", + prompt: "Implement.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + status: "succeeded", + }, "2026-06-11T00:02:00.000Z"); + const secondStep = store.createChannelWorkflowStep({ + workflowId: workflow.id, + position: 2, + title: "Verification", + prompt: "Verify.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + status: "succeeded", + }, "2026-06-11T00:03:00.000Z"); + const verificationRun = store.createRun({ + id: "run_ling_agent_session_final_verification", + cwd, + taskId: "task_ling_agent_session_final_verification", + channelId: channel.id, + teammateId: "ling_agent", + workflowStepId: secondStep.id, + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_ling_agent_session_final_verification", title: "Verify feature" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-11T00:03:30.000Z", + }); + store.markRunSucceeded(verificationRun.id, "2026-06-11T00:04:30.000Z"); + const artifact = store.createArtifact({ + runId: verificationRun.id, + type: "file", + path: `${cwd}/src/feature.ts`, + title: "feature.ts", + contentType: "text/typescript; charset=utf-8", + sizeBytes: 128, + createdAt: "2026-06-11T00:04:45.000Z", + }); + store.updateChannelWorkflowStep(firstStep.id, { resultSummary: "Implemented feature." }, "2026-06-11T00:04:00.000Z"); + store.updateChannelWorkflowStep(secondStep.id, { + runId: verificationRun.id, + resultSummary: "Verified checks.", + metadata: { outputArtifactIds: [artifact.id] }, + }, "2026-06-11T00:05:00.000Z"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "succeeded"); + assert.equal(view.finalResult?.outcome, "succeeded"); + assert.equal(view.finalResult?.summary, "Verified checks."); + assert.deepEqual(view.finalResult?.changedPaths, [`${cwd}/src/feature.ts`]); + assert.deepEqual(view.finalResult?.artifactIds, [artifact.id]); + assert.deepEqual(view.finalResult?.checks.map((check) => check.title), ["Implementation", "Verification"]); + assert.ok(view.evidenceRefs.some((ref) => ref.type === "workflow" && ref.id === workflow.id)); + assert.ok(view.evidenceRefs.some((ref) => ref.type === "run" && ref.id === verificationRun.id)); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view projects blocked workflow final result with next action", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-blocked-workflow-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "需要写文件"); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Create README.md", + objective: "Create the requested file.", + status: "blocked", + createdBy: "ling_agent", + }, "2026-06-11T00:01:00.000Z"); + const step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + position: 1, + title: "Create file", + prompt: "Create README.md.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + status: "blocked", + }, "2026-06-11T00:02:00.000Z"); + store.updateChannelWorkflowStep(step.id, { blocker: "Write permission was not granted." }, "2026-06-11T00:03:00.000Z"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "blocked"); + assert.equal(view.finalResult?.outcome, "blocked"); + assert.equal(view.finalResult?.summary, "Write permission was not granted."); + assert.deepEqual(view.finalResult?.nextActions, ["Write permission was not granted."]); + assert.deepEqual(view.finalResult?.checks, [{ + id: step.id, + title: "Create file", + status: "blocked", + summary: "Write permission was not granted.", + }]); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view projects terminal failed run", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-failed-run-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "修复失败任务"); + const run = store.createRun({ + id: "run_ling_agent_session_failed", + cwd, + taskId: "task_ling_agent_session_failed", + channelId: channel.id, + teammateId: "ling_agent", + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_ling_agent_session_failed", title: "Fix failing task" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-11T00:01:00.000Z", + }); + store.markRunFailed(run.id, "2026-06-11T00:02:00.000Z", "Typecheck failed."); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "failed"); + assert.equal(view.finalResult?.outcome, "failed"); + assert.equal(view.finalResult?.title, "Fix failing task"); + assert.equal(view.finalResult?.summary, "Typecheck failed."); + assert.deepEqual(view.finalResult?.nextActions, ["Typecheck failed."]); + assert.ok(view.evidenceRefs.some((ref) => ref.type === "run" && ref.id === run.id)); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent session view reports denied approval for terminal run", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-denied-approval-")); + const store = createSqliteRunStore(cwd); + try { + const { channel } = createStoreChannelAndMessage(store, cwd, "创建 README.md"); + const run = store.createRun({ + id: "run_ling_agent_session_denied_approval", + cwd, + taskId: "task_ling_agent_session_denied_approval", + channelId: channel.id, + teammateId: "ling_agent", + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_ling_agent_session_denied_approval", title: "Create README.md" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-11T00:01:00.000Z", + }); + const approval = store.createApprovalRequest({ + runId: run.id, + taskId: run.taskId, + channelId: channel.id, + actionType: "write", + risk: "medium", + title: "Approve README.md write", + description: "Allow creating README.md.", + payload: { type: "file_write", path: `${cwd}/README.md`, bytes: 16 }, + expiresAt: "2999-01-01T00:00:00.000Z", + }, "2026-06-11T00:02:00.000Z"); + store.denyApprovalRequest(approval.id, { reason: "Need a narrower path.", resolvedBy: "local-user" }, "2026-06-11T00:03:00.000Z"); + store.markRunCancelled(run.id, "2026-06-11T00:04:00.000Z"); + + const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); + + assert.equal(view.status, "cancelled"); + assert.equal(view.finalResult?.outcome, "cancelled"); + assert.equal(view.finalResult?.summary, "Approval denied: Approve README.md write."); + assert.deepEqual(view.finalResult?.nextActions, ["Need a narrower path."]); + assert.deepEqual(view.finalResult?.approvals, [{ + approvalId: approval.id, + actionType: "write", + status: "denied", + title: "Approve README.md write", + }]); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent coordinator queues active run follow-up before ordinary routing", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-followup-")); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent Coordinator", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const run = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_ling_agent_coordinator_followup", + taskId: "task_ling_agent_coordinator_followup", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "完成后也更新 docs", + replyDisposition: { mode: "queue_followup", activeRunId: run.id }, + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.replyDispatch.disposition, "followup_queued"); + assert.equal(created.replyDispatch.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.mode, "queue_followup"); + const interventions = store.listRunInterventions({ sourceRunId: run.id }); + assert.deepEqual(interventions.map((intervention) => intervention.id), [created.replyDispatch.intervention.id]); + assert.deepEqual(store.listEvents(run.id).map((event) => event.type), ["run_intervention_created", "run_followup_queued"]); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("active channel reply does not intercept an unmarked direct workflow request", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-active-reply-direct-workflow-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Direct workflow while reply is active", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const activeRun = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_active_reply_before_execute", + taskId: "task_active_reply_before_execute", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `在 ${cwd} 下创建 README.md`, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.replyDispatch, undefined); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); + assert.deepEqual(store.listRunInterventions({ sourceRunId: activeRun.id }), []); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("root reply guidance cannot capture a branch turn and branch continuation stays on its thread", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-reply-branch-lineage-")); + await ensureGitRepo(cwd); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Reply branch lineage", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 4, + schedulingMode: "smart", + writeLockScope: "channel", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const branch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { + name: "Independent branch", + })).branch; + const store = stores.get(cwd); + assert.ok(store); + const rootRun = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_root_reply_before_branch", + taskId: "task_root_reply_before_branch", + }); + + const branchSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: branch.rootThreadId }, + body: "Start an independent branch reply.", + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup", activeRunId: rootRun.id }, + }); + + assert.equal(branchSource.replyDispatch.disposition, "reply_requested"); + assert.equal(branchSource.aiReply.threadId, branch.rootThreadId); + assert.deepEqual(store.listRunInterventions({ sourceRunId: rootRun.id }), []); + await waitForCondition(() => { + const failed = store.listRuns({ limit: 20 }).find(run => run.threadId === branch.rootThreadId && run.status === "failed"); + assert.equal(failed?.errorMessage, undefined); + return runner.runs.length === 1; + }, "branch reply run to start"); + const branchRun = store.listRuns({ limit: 20 }).find((run) => + run.channelId === channel.id && run.threadId === branch.rootThreadId && run.id !== rootRun.id + ); + assert.ok(branchRun); + assert.equal(branchRun.threadId, branch.rootThreadId); + + const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: branch.rootThreadId }, + body: "Continue only inside this branch.", + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup", activeRunId: branchRun.id }, + }); + assert.equal(guidance.replyDispatch.disposition, "followup_queued"); + assert.equal(guidance.replyDispatch.sourceRunId, branchRun.id); + + runner.resolve("Initial branch reply"); + await waitForCondition(() => runner.runs.length === 2, "branch continuation to start"); + await waitForCondition( + () => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.continuationRunId, + "branch continuation to link", + ); + const intervention = store.getRunIntervention(guidance.replyDispatch.intervention.id); + const continuationRun = store.getRun(intervention.continuationRunId); + assert.ok(continuationRun); + assert.equal(continuationRun.threadId, branch.rootThreadId); + runner.resolve("Branch continuation reply"); + } finally { + for (let index = 0; index < 3; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("topic channel replies keep canonical LingThread identity and isolate guidance by source message lineage", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-topic-reply-lineage-")); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Topic reply lineage", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + + const topicRoot = appendChannelItemFixture(store, { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "Topic root message.", + status: "ready", + }, "2026-08-08T00:00:00.000Z"); + const topicSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: topicRoot.id }, + body: "Start a reply inside this topic.", + requestAiReply: true, + providerPresetId: provider.id, + }); + await waitForCondition(() => runner.runs.length === 1, "topic reply run to start"); + const topicRun = store.listRuns({ limit: 20 }).find((run) => + run.channelId === channel.id && !run.parentRunId + ); + assert.ok(topicRun); + assert.equal(topicRun.requestSnapshot.sourceMessageId, topicSource.message.id); + assert.match(topicRun.threadId, /^thread_/); + assert.notEqual(topicRun.threadId, topicRoot.id); + + const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "thread", threadId: topicSource.thread.id }, + body: "Continue only inside this topic.", + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup", activeRunId: topicRun.id }, + }); + assert.equal(guidance.replyDispatch.disposition, "followup_queued"); + assert.equal(guidance.replyDispatch.sourceRunId, topicRun.id); + + runner.resolve("Initial topic reply"); + await waitForCondition(() => runner.runs.length === 2, "topic continuation to start"); + await waitForCondition( + () => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.continuationRunId, + "topic continuation to link", + ); + const intervention = store.getRunIntervention(guidance.replyDispatch.intervention.id); + const continuationRun = store.getRun(intervention.continuationRunId); + assert.equal(continuationRun.threadId, topicRun.threadId); + assert.equal(continuationRun.requestSnapshot.sourceMessageId, guidance.message.id); + runner.resolve("Topic continuation reply"); + } finally { + for (let index = 0; index < 3; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent coordinator queues attachment-only active run guidance", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-attachment-followup-")); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent Coordinator Attachment", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const run = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_ling_agent_coordinator_attachment_followup", + taskId: "task_ling_agent_coordinator_attachment_followup", + }); + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "followup.txt", + contentType: "text/plain", + contentBase64: Buffer.from("active run follow-up context", "utf8").toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "", + attachmentIds: [attachment.id], + replyDisposition: { mode: "queue_followup", activeRunId: run.id }, + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.replyDispatch.disposition, "followup_queued"); + assert.equal(created.replyDispatch.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); + assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, [attachment.id]); + assert.equal(created.replyDispatch.intervention.messagePayload.body, ""); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent coordinator queues file-ref-only active run guidance", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-file-ref-followup-")); + await writeFile(resolve(cwd, "README.md"), "# File ref guidance\n", "utf8"); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent Coordinator File Ref", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const run = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_ling_agent_coordinator_file_ref_followup", + taskId: "task_ling_agent_coordinator_file_ref_followup", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "", + fileRefs: [{ path: "README.md", label: "README.md", source: "composer" }], + replyDisposition: { mode: "queue_followup", activeRunId: run.id }, + }); + + assert.equal(created.aiReply, undefined); + assert.deepEqual(created.message.fileRefs, [{ path: "README.md", label: "README.md", source: "composer" }]); + assert.equal(created.replyDispatch.disposition, "followup_queued"); + assert.equal(created.replyDispatch.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.messagePayload.body, ""); + assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, []); + assert.deepEqual(created.replyDispatch.intervention.messagePayload.linkPreviewIds, []); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].path, "README.md"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].rootRelation, "inside_workspace"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].kind, "file"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].materializationStatus, "metadata_only"); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent turn envelope merges persisted and explicit text file refs safely", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-turn-file-refs-")); + await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); + const store = createSqliteRunStore(cwd); + try { + const channel = store.createChannel({ + name: "Ling Agent File Refs", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Also inspect file:README.md and path:/tmp/outside.txt", + fileRefs: [{ path: "README.md", label: "README.md", source: "composer" }], + }, "2026-06-11T00:01:00.000Z"); + + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); + + assert.deepEqual(envelope.fileRefs.map((ref) => ref.path), ["README.md", "/tmp/outside.txt"]); + assert.equal(envelope.fileRefs[0].source, "composer"); + assert.equal(envelope.fileRefs[0].rootRelation, "inside_workspace"); + assert.equal(envelope.fileRefs[0].kind, "file"); + assert.equal(envelope.fileRefs[1].source, "explicit_text"); + assert.equal(envelope.fileRefs[1].rootRelation, "outside_workspace"); + assert.equal(envelope.fileRefs[1].materializationStatus, "blocked"); + assert.match(envelope.fileRefs[1].blockedReason, /outside the active workspace/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel input materialization cache upserts and feeds turn envelope", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-input-materialization-cache-")); + const store = createSqliteRunStore(cwd); + try { + const channel = store.createChannel({ + name: "Materialization cache", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const attachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "screenshot.png", + contentType: "image/png", + sizeBytes: 128, + path: "screenshot.png", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const fingerprint = attachmentMaterializationFingerprint(attachment); + + const first = store.createChannelInputMaterialization({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + sourceFingerprint: fingerprint, + extractorId: "image_vision_summary_v1", + extractorVersion: "1", + contentKind: "vision_summary", + status: "available", + contentText: "The screenshot shows a save button overlapping the sidebar.", + byteCount: 128, + tokenEstimate: 12, + policyDecisionJson: { source: "test" }, + }, "2026-06-11T00:02:00.000Z"); + const second = store.createChannelInputMaterialization({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + sourceFingerprint: fingerprint, + extractorId: "image_vision_summary_v1", + extractorVersion: "1", + contentKind: "vision_summary", + status: "available", + contentText: "Updated screenshot summary from cache.", + truncated: true, + }, "2026-06-11T00:03:00.000Z"); + + assert.equal(second.id, first.id); + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].contentText, "Updated screenshot summary from cache."); + assert.equal(rows[0].truncated, true); + assert.deepEqual(first.policyDecisionJson, { source: "test" }); + + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Fix this screenshot", + attachmentIds: [attachment.id], + }, "2026-06-11T00:04:00.000Z"); + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); + + assert.equal(envelope.attachments.length, 1); + assert.equal(envelope.attachments[0].materializationStatus, "available"); + assert.equal(envelope.attachments[0].materializationId, first.id); + assert.equal(envelope.attachments[0].previewStatus, "available"); + assert.equal(envelope.attachments[0].previewText, "Updated screenshot summary from cache."); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("inline materialization writes image vision cache with injected summarizer", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-inline-cache-")); + const store = createSqliteRunStore(cwd); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + await writeFile(resolve(store.paths.attachmentsDir, "inline.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const channel = store.createChannel({ + name: "Image vision materialization", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const attachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "inline.png", + contentType: "image/png", + sizeBytes: 8, + path: "inline.png", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Please handle this.", + attachmentIds: [attachment.id], + }, "2026-06-11T00:02:00.000Z"); + + let summarizerCalls = 0; + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: message, + now: "2026-06-11T00:03:00.000Z", + imageVisionSummarizer: async (input) => { + summarizerCalls += 1; + assert.equal(input.attachment.id, attachment.id); + assert.equal(input.maxBytes, 5 * 1024 * 1024); + assert.equal(input.maxChars, 4_000); + assert.match(input.absolutePath, /inline\.png$/); + return { + contentText: "The screenshot shows a save button overlapping the sidebar.", + structuredSummary: { source: "test_summarizer" }, + policyDecision: { mode: "test" }, + }; + }, + }); + + assert.equal(summarizerCalls, 1); + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].extractorId, "image_vision_summary_v1"); + assert.equal(rows[0].contentKind, "vision_summary"); + assert.equal(rows[0].status, "available"); + assert.match(rows[0].contentText, /save button overlapping/); + assert.deepEqual(rows[0].structuredSummaryJson, { source: "test_summarizer" }); + assert.deepEqual(rows[0].policyDecisionJson, { mode: "test" }); + + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); + assert.equal(envelope.attachments[0].materializationStatus, "available"); + assert.equal(envelope.attachments[0].previewStatus, "available"); + assert.match(envelope.attachments[0].previewText, /save button overlapping/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("inline image materialization reuses same image content after provider timeout", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-content-cache-")); + const store = createSqliteRunStore(cwd); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + const imageBytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]); + await writeFile(resolve(store.paths.attachmentsDir, "first.png"), imageBytes); + await writeFile(resolve(store.paths.attachmentsDir, "second.png"), imageBytes); + const channel = store.createChannel({ + name: "Image vision content cache", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const firstAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "same.png", + contentType: "image/png", + sizeBytes: imageBytes.byteLength, + path: "first.png", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const firstMessage = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "看下这个图片", + attachmentIds: [firstAttachment.id], + }, "2026-06-11T00:02:00.000Z"); + + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: firstMessage, + now: "2026-06-11T00:03:00.000Z", + imageVisionSummarizer: async () => ({ + contentText: "The image shows a red banner with visible Chinese text.", + structuredSummary: { source: "first_pass" }, + }), + }); + + const secondAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "same.png", + contentType: "image/png", + sizeBytes: imageBytes.byteLength, + path: "second.png", + createdAt: "2026-06-11T00:04:00.000Z", + }); + const secondMessage = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "同一张图再看一次", + attachmentIds: [secondAttachment.id], + }, "2026-06-11T00:05:00.000Z"); + + let timeoutSummarizerCalls = 0; + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: secondMessage, + now: "2026-06-11T00:06:00.000Z", + imageVisionSummarizer: async () => { + timeoutSummarizerCalls += 1; + throw new Error("The operation was aborted due to timeout"); + }, + }); + + assert.equal(timeoutSummarizerCalls, 0); + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: secondAttachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].status, "available"); + assert.match(rows[0].contentText, /red banner/); + + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: secondMessage }); + assert.equal(envelope.attachments[0].materializationStatus, "available"); + assert.match(envelope.attachments[0].previewText, /red banner/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("retryable image materialization failures are not cached permanently", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-timeout-retry-")); + const store = createSqliteRunStore(cwd); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + const imageBytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 9, 8, 7, 6]); + await writeFile(resolve(store.paths.attachmentsDir, "retry.png"), imageBytes); + const channel = store.createChannel({ + name: "Image vision timeout retry", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const attachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "retry.png", + contentType: "image/png", + sizeBytes: imageBytes.byteLength, + path: "retry.png", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "看下这个图片", + attachmentIds: [attachment.id], + }, "2026-06-11T00:02:00.000Z"); + + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: message, + now: "2026-06-11T00:03:00.000Z", + imageVisionSummarizer: async () => { + throw new Error("The operation was aborted due to timeout"); + }, + }); + assert.equal(store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }).length, 0); + + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: message, + now: "2026-06-11T00:04:00.000Z", + imageVisionSummarizer: async () => ({ + contentText: "The retried image summary is available.", + }), + }); + + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].status, "available"); + assert.match(rows[0].contentText, /retried image summary/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("teammate direct materialization reads image attachments with injected summarizer", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-image-vision-")); + const store = createSqliteRunStore(cwd); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + await writeFile(resolve(store.paths.attachmentsDir, "direct-image.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const teammate = store.createAiTeammate({ + roleKey: "engineer", + displayName: "Engineer", + description: "Helps with private chat.", + avatarTone: "blue", + approvalProfile: { mode: "none" }, + status: "active", + }, "2026-06-11T01:00:00.000Z"); + const thread = store.getOrCreateTeammateDirectThread(teammate.id, "2026-06-11T01:01:00.000Z"); + const message = store.createTeammateDirectMessage({ + threadId: thread.id, + teammateId: teammate.id, + authorType: "local-user", + authorId: "local-user", + body: "Look at this screenshot.", + status: "ready", + }, "2026-06-11T01:02:00.000Z"); + const attachment = store.createTeammatePrivateAttachment({ + teammateId: teammate.id, + threadId: thread.id, + messageId: message.id, + scope: "teammate_direct_thread", + kind: "image", + fileName: "direct-image.png", + contentType: "image/png", + sizeBytes: 8, + path: "direct-image.png", + createdAt: "2026-06-11T01:03:00.000Z", + }); + const messageWithAttachment = store.createTeammateDirectMessage({ + threadId: thread.id, + teammateId: teammate.id, + authorType: "local-user", + authorId: "local-user", + body: "Please read this image.", + attachmentIds: [attachment.id], + status: "ready", + }, "2026-06-11T01:04:00.000Z"); + + let summarizerCalls = 0; + const materialized = await materializeInlineTeammateDirectInputs({ + store, + teammateId: teammate.id, + threadId: thread.id, + sourceMessage: messageWithAttachment, + now: "2026-06-11T01:05:00.000Z", + imageVisionSummarizer: async (input) => { + summarizerCalls += 1; + assert.equal(input.attachment.id, attachment.id); + assert.equal(input.maxBytes, 5 * 1024 * 1024); + assert.equal(input.maxChars, 4_000); + assert.match(input.absolutePath, /direct-image\.png$/); + return { + contentText: "The image shows an error toast above a disabled send button.", + structuredSummary: { source: "direct_test_summarizer" }, + }; + }, + }); + + assert.equal(summarizerCalls, 1); + const result = materialized.get(attachment.id); + assert.equal(result?.status, "available"); + assert.equal(result?.kind, "image"); + assert.match(result?.contentText ?? "", /disabled send button/); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("public link body extractor strips HTML and blocks unsafe redirects", async () => { + const linkPreview = { + id: "link_preview_public_docs", + workspaceId: "workspace_default", + channelId: "channel_docs", + url: "https://example.com/docs", + normalizedUrl: "https://example.com/docs", + title: "Docs", + status: "ready", + createdAt: "2026-06-11T00:00:00.000Z", + updatedAt: "2026-06-11T00:00:00.000Z", + }; + const success = await linkBodyExtractorV1.materialize({ + workspaceId: "workspace_default", + channelId: "channel_docs", + sourceKind: "link_preview", + sourceId: linkPreview.id, + sourceFingerprint: "fingerprint", + requestedBy: "turn_envelope", + maxBytes: 4096, + maxChars: 1000, + now: "2026-06-11T00:00:00.000Z", + linkPreview, + resolveHostAddresses: async () => ["93.184.216.34"], + fetch: async () => new Response(` + + Docs + + +

Public guide

Use this body for the summary.

+ + + `, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }), + }); + + assert.equal(success.status, "available"); + assert.equal(success.contentKind, "html_text"); + assert.match(success.contentText, /Public guide/); + assert.match(success.contentText, /Use this body for the summary/); + assert.doesNotMatch(success.contentText, /window\.secret|Navigation noise/); + + let calls = 0; + const redirected = await linkBodyExtractorV1.materialize({ + workspaceId: "workspace_default", + channelId: "channel_docs", + sourceKind: "link_preview", + sourceId: linkPreview.id, + sourceFingerprint: "fingerprint", + requestedBy: "turn_envelope", + maxBytes: 4096, + maxChars: 1000, + now: "2026-06-11T00:00:00.000Z", + linkPreview, + resolveHostAddresses: async () => ["93.184.216.34"], + fetch: async () => { + calls += 1; + return new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1/private" }, + }); + }, + }); + + assert.equal(calls, 1); + assert.equal(redirected.status, "blocked"); + assert.equal(redirected.errorCode, "local_or_private_host"); + + let privateFetchCalled = false; + const privateBlocked = await linkBodyExtractorV1.materialize({ + workspaceId: "workspace_default", + channelId: "channel_docs", + sourceKind: "link_preview", + sourceId: "link_preview_private", + sourceFingerprint: "fingerprint", + requestedBy: "turn_envelope", + maxBytes: 4096, + maxChars: 1000, + now: "2026-06-11T00:00:00.000Z", + linkPreview: { + ...linkPreview, + id: "link_preview_private", + url: "http://127.0.0.1/private", + normalizedUrl: "http://127.0.0.1/private", + }, + fetch: async () => { + privateFetchCalled = true; + return new Response("should not fetch", { status: 200 }); + }, + }); + + assert.equal(privateBlocked.status, "blocked"); + assert.equal(privateBlocked.errorCode, "local_or_private_host"); + assert.equal(privateFetchCalled, false); +}); + +test("A11 preview and body fresh native connections consume audited DNS addresses", () => { + for (const caseName of ["preview-native", "body-native"]) { + const childEnv = { ...process.env, LING_A11_SOCKET_CASE: caseName }; + delete childEnv.NODE_TEST_CONTEXT; + const result = spawnSync(process.execPath, ["--test", "--test-name-pattern=^A11 fixture child$", + new URL("./media-native-transport.test.mjs", import.meta.url).pathname], { + env: childEnv, encoding: "utf8", timeout: 20_000, + }); + assert.equal(result.error, undefined, `${caseName}: ${result.error?.message}`); + assert.match(result.stdout, new RegExp(`A11 child entered: ${caseName}`), "child must execute its socket assertions"); + assert.equal(result.status, 0, `${caseName}\n${result.stdout}\n${result.stderr}`); + } +}); + +test("inline materialization writes public link body cache for explicit link request", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-inline-cache-")); + const store = createSqliteRunStore(cwd); + try { + const channel = store.createChannel({ + name: "Link body materialization", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const linkPreview = store.createChannelLinkPreview({ + channelId: channel.id, + url: "https://example.com/docs", + normalizedUrl: "https://example.com/docs", + title: "Example Docs", + description: "Metadata only before body extraction.", + siteName: "Example", + status: "ready", + }, "2026-06-11T00:01:00.000Z"); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Please summarize this link", + linkPreviewIds: [linkPreview.id], + }, "2026-06-11T00:02:00.000Z"); + + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: message, + now: "2026-06-11T00:03:00.000Z", + resolveHostAddresses: async () => ["93.184.216.34"], + fetch: async () => new Response("

Inline public body cache.

", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }), + }); + + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "link_preview", + sourceId: linkPreview.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].extractorId, "link_body_v1"); + assert.equal(rows[0].status, "available"); + assert.match(rows[0].contentText, /Inline public body cache/); + + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); + assert.equal(envelope.linkPreviews[0].materializationStatus, "available"); + assert.equal(envelope.linkPreviews[0].materializationId, rows[0].id); + assert.match(envelope.linkPreviews[0].previewText, /Inline public body cache/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("cached public link body lets Ling answer without materialization clarify", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-cached-reply-")); + const runner = new ScriptedRunner(["这个页面讲的是 cached public link body。"]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { defaultWorkspaceCwd: cwd }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Cached link body reply", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const linkPreview = store.createChannelLinkPreview({ + channelId: channel.id, + url: "https://example.com/guide", + normalizedUrl: "https://example.com/guide", + title: "Guide", + description: "Metadata before cached body.", + siteName: "Example", + status: "ready", + }, "2026-06-11T00:01:00.000Z"); + store.createChannelInputMaterialization({ + channelId: channel.id, + sourceKind: "link_preview", + sourceId: linkPreview.id, + sourceFingerprint: linkPreviewMaterializationFingerprint(linkPreview), + extractorId: "link_body_v1", + extractorVersion: "1", + contentKind: "html_text", + status: "available", + contentText: "Cached public link body for Ling to summarize.", + byteCount: 48, + tokenEstimate: 12, + }, "2026-06-11T00:02:00.000Z"); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Please summarize this link", + linkPreviewIds: [linkPreview.id], + }); + + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "这个页面讲的是 cached public link body。"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Cached public link body for Ling to summarize/); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("pdf and OOXML extractors materialize bounded attachment text", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-extractors-")); + const store = createSqliteRunStore(cwd); + const officeArtifacts = new OfficeArtifactService(); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + const channel = store.createChannel({ + name: "Document extractors", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + + const pdfBytes = await readPdfFixtureBytes(); + await writeFile(resolve(store.paths.attachmentsDir, "fixture.pdf"), pdfBytes); + const pdfAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "pdf", + fileName: "fixture.pdf", + contentType: "application/pdf", + sizeBytes: pdfBytes.byteLength, + path: "fixture.pdf", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const pdfResult = await pdfExtractorV1.materialize({ + workspaceId: pdfAttachment.workspaceId, + channelId: channel.id, + sourceKind: "attachment", + sourceId: pdfAttachment.id, + sourceFingerprint: "pdf-fingerprint", + requestedBy: "turn_envelope", + maxBytes: 128 * 1024, + maxChars: 100, + now: "2026-06-11T00:02:00.000Z", + attachment: pdfAttachment, + attachmentsDir: store.paths.attachmentsDir, + }); + assert.equal(pdfResult.status, "available", JSON.stringify(pdfResult)); + assert.equal(pdfResult.contentKind, "pdf_text"); + assert.match(pdfResult.contentText, /Ling PDF fixture/); + assert.equal(pdfResult.structuredSummary.pageCount, 1); + + const docxBytes = await readDocxFixtureBytes(); + await writeFile(resolve(store.paths.attachmentsDir, "fixture.docx"), docxBytes); + const docxAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "office", + fileName: "fixture.docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + sizeBytes: docxBytes.byteLength, + path: "fixture.docx", + createdAt: "2026-06-11T00:03:00.000Z", + }); + const docxResult = await officeExtractorV1.materialize({ + workspaceId: docxAttachment.workspaceId, + channelId: channel.id, + sourceKind: "attachment", + sourceId: docxAttachment.id, + sourceFingerprint: "docx-fingerprint", + requestedBy: "turn_envelope", + maxBytes: 128 * 1024, + maxChars: 100, + now: "2026-06-11T00:04:00.000Z", + attachment: docxAttachment, + attachmentsDir: store.paths.attachmentsDir, + }); + assert.equal(docxResult.status, "available", JSON.stringify(docxResult)); + assert.equal(docxResult.contentKind, "office_text"); + assert.match(docxResult.contentText, /Walking on imported air/); + assert.deepEqual(docxResult.structuredSummary, { format: "docx", warnings: [] }); + + const pptxPath = resolve(store.paths.attachmentsDir, "slides.pptx"); + await officeArtifacts.createPresentation(cwd, { + path: pptxPath, + title: "Ling Office fixture", + slides: [{ title: "Native presentation", bullets: ["Bounded extraction"] }], + }); + const pptxBytes = await readFile(pptxPath); + const pptxAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "office", + fileName: "slides.pptx", + contentType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + sizeBytes: pptxBytes.byteLength, + path: "slides.pptx", + createdAt: "2026-06-11T00:05:00.000Z", + }); + const pptxResult = await officeExtractorV1.materialize({ + workspaceId: pptxAttachment.workspaceId, + channelId: channel.id, + sourceKind: "attachment", + sourceId: pptxAttachment.id, + sourceFingerprint: "pptx-fingerprint", + requestedBy: "turn_envelope", + maxBytes: 512 * 1024, + maxChars: 100, + now: "2026-06-11T00:06:00.000Z", + attachment: pptxAttachment, + attachmentsDir: store.paths.attachmentsDir, + }); + assert.equal(pptxResult.status, "available", JSON.stringify(pptxResult)); + assert.match(pptxResult.contentText, /Native presentation/u); + assert.deepEqual(pptxResult.structuredSummary, { format: "pptx", warnings: [] }); + + const xlsxPath = resolve(store.paths.attachmentsDir, "metrics.xlsx"); + await officeArtifacts.createWorkbook(cwd, { + path: xlsxPath, + sheets: [{ name: "Summary", rows: [["Metric", "Value"], ["Active users", 42]] }], + }); + const xlsxBytes = await readFile(xlsxPath); + const xlsxAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "office", + fileName: "metrics.xlsx", + contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + sizeBytes: xlsxBytes.byteLength, + path: "metrics.xlsx", + createdAt: "2026-06-11T00:07:00.000Z", + }); + const xlsxResult = await officeExtractorV1.materialize({ + workspaceId: xlsxAttachment.workspaceId, + channelId: channel.id, + sourceKind: "attachment", + sourceId: xlsxAttachment.id, + sourceFingerprint: "xlsx-fingerprint", + requestedBy: "turn_envelope", + maxBytes: 512 * 1024, + maxChars: 100, + now: "2026-06-11T00:08:00.000Z", + attachment: xlsxAttachment, + attachmentsDir: store.paths.attachmentsDir, + }); + assert.equal(xlsxResult.status, "available", JSON.stringify(xlsxResult)); + assert.match(xlsxResult.contentText, /Active users/u); + assert.deepEqual(xlsxResult.structuredSummary, { format: "xlsx", warnings: [] }); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("inline materialization writes pdf and docx cache for current-turn attachments", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-inline-cache-")); + const store = createSqliteRunStore(cwd); + try { + await mkdir(store.paths.attachmentsDir, { recursive: true }); + const channel = store.createChannel({ + name: "Document inline cache", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + }, "2026-06-11T00:00:00.000Z"); + const pdfBytes = await readPdfFixtureBytes(); + await writeFile(resolve(store.paths.attachmentsDir, "inline.pdf"), pdfBytes); + const pdfAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "pdf", + fileName: "inline.pdf", + contentType: "application/pdf", + sizeBytes: pdfBytes.byteLength, + path: "inline.pdf", + createdAt: "2026-06-11T00:01:00.000Z", + }); + const docxBytes = await readDocxFixtureBytes(); + await writeFile(resolve(store.paths.attachmentsDir, "inline.docx"), docxBytes); + const docxAttachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "office", + fileName: "inline.docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + sizeBytes: docxBytes.byteLength, + path: "inline.docx", + createdAt: "2026-06-11T00:02:00.000Z", + }); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Context attached.", + attachmentIds: [pdfAttachment.id, docxAttachment.id], + }, "2026-06-11T00:03:00.000Z"); + + await materializeInlineChannelInputs({ + store, + channel, + sourceMessage: message, + now: "2026-06-11T00:04:00.000Z", + }); + + const pdfRows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: pdfAttachment.id, + }); + assert.equal(pdfRows.length, 1); + assert.equal(pdfRows[0].extractorId, "pdf_text_v1"); + assert.equal(pdfRows[0].status, "available", JSON.stringify(pdfRows[0])); + assert.match(pdfRows[0].contentText, /Ling PDF fixture/); + + const docxRows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: docxAttachment.id, + }); + assert.equal(docxRows.length, 1); + assert.equal(docxRows[0].extractorId, "office_text_v1"); + assert.equal(docxRows[0].status, "available", JSON.stringify(docxRows[0])); + assert.match(docxRows[0].contentText, /Walking on imported air/); + + const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); + assert.deepEqual(envelope.attachments.map((attachment) => attachment.materializationStatus), ["available", "available"]); + assert.match(envelope.attachments[0].previewText, /Ling PDF fixture/); + assert.match(envelope.attachments[1].previewText, /Walking on imported air/); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("materialized pdf and docx attachments let Ling answer without materialization clarify", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-materialized-reply-")); + const runner = new ScriptedRunner(["我读到了 PDF 和 DOCX 的正文。"]); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Materialized document reply", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + const pdfAttachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "guide.pdf", + contentType: "application/pdf", + contentBase64: (await readPdfFixtureBytes()).toString("base64"), + })).attachment; + const docxAttachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "brief.docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + contentBase64: (await readDocxFixtureBytes()).toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Please summarize this PDF and this document", + attachmentIds: [pdfAttachment.id, docxAttachment.id], + }); + + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "我读到了 PDF 和 DOCX 的正文。"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Ling PDF fixture/); + assert.match(runner.runs[0].task.prompt, /Walking on imported air/); + const pdfPreview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(pdfAttachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=channel&channelId=${encodeURIComponent(channel.id)}`); + assert.equal(pdfPreview.kind, "pdf"); + assert.match(pdfPreview.text, /Ling PDF fixture/); + const docxPreview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(docxAttachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=channel&channelId=${encodeURIComponent(channel.id)}`); + assert.equal(docxPreview.kind, "office"); + assert.match(docxPreview.text, /Walking on imported air/); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("materialized image attachment lets Ling answer without materialization clarify", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-materialized-reply-")); + const runner = new ScriptedRunner(["我读到了截图摘要。"]); + const daemon = await startDaemonForTest(runner, { + imageVisionSummarizer: async () => ({ + contentText: "The screenshot shows the branch button hidden next to the channel title.", + structuredSummary: { source: "test_summarizer" }, + }), + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Materialized image reply", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "screenshot.png", + contentType: "image/png", + contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Please fix the UI from this screenshot", + attachmentIds: [attachment.id], + requestAiReply: true, + }); + + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "我读到了截图摘要。"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /branch button hidden/); + assert.doesNotMatch(runner.runs[0].task.prompt, /Image vision summarizer is not configured/); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("provider-backed image materialization works without injected summarizer", async (t) => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-provider-backed-image-materialization-")); + const relay = await startMockOpenAICompatibleServer(t, { + responseContent: ({ body }) => { + const content = body?.messages?.[0]?.content; + if (Array.isArray(content) && content.some((part) => part?.type === "image_url")) { + return "The screenshot shows a settings dialog with the permission section highlighted."; + } + return "OK"; + }, + }); + const runner = new ScriptedRunner(["我读到了 provider 视觉摘要。"]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + kind: "openai-compatible", + displayName: "Vision Relay", + providerId: "relay", + model: "vision-chat", + api: "openai-completions", + baseUrl: `${relay.url}/v1`, + capabilities: ["chat"], + input: ["text"], + authHeader: false, + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Provider backed image reply", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "settings.png", + contentType: "image/png", + contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "为什么还是这样?", + attachmentIds: [attachment.id], + requestAiReply: true, + }); + + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "我读到了 provider 视觉摘要。"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /permission section highlighted/); + assert.doesNotMatch(runner.runs[0].task.prompt, /contents aren't available|Image vision summarizer is not configured/i); + + const store = stores.get(cwd); + assert.ok(store); + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].extractorId, "image_vision_summary_v1"); + assert.equal(rows[0].status, "available", JSON.stringify(rows[0])); + assert.match(rows[0].contentText, /settings dialog/); + + const visionRequest = relay.requests.find((request) => Array.isArray(request.body?.messages?.[0]?.content)); + assert.ok(visionRequest); + const imagePart = visionRequest.body.messages[0].content.find((part) => part.type === "image_url"); + assert.match(imagePart.image_url.url, /^data:image\/png;base64,/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent coordinator preserves materialized guidance metadata for active run continuation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-materialized-guidance-")); + await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent Materialized Guidance", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const run = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_ling_agent_materialized_guidance", + taskId: "task_ling_agent_materialized_guidance", + }); + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "followup.txt", + contentType: "text/plain", + contentBase64: Buffer.from("active run follow-up context", "utf8").toString("base64"), + })).attachment; + const linkPreview = store.createChannelLinkPreview({ + channelId: channel.id, + url: "https://example.com/docs", + normalizedUrl: "https://example.com/docs", + title: "Example Docs", + description: "Cached link-preview metadata only.", + siteName: "Example", + status: "ready", + }, "2026-06-11T00:02:30.000Z"); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "另外也更新 file:README.md", + attachmentIds: [attachment.id], + linkPreviewIds: [linkPreview.id], + replyDisposition: { mode: "queue_followup", activeRunId: run.id }, + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.replyDispatch.disposition, "followup_queued"); + assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); + assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, [attachment.id]); + assert.deepEqual(created.replyDispatch.intervention.messagePayload.linkPreviewIds, [linkPreview.id]); + assert.equal(created.replyDispatch.intervention.messagePayload.attachments[0].fileName, "followup.txt"); + assert.equal(created.replyDispatch.intervention.messagePayload.attachments[0].materializationStatus, "available"); + assert.match(created.replyDispatch.intervention.messagePayload.attachments[0].previewText, /active run follow-up context/); + assert.equal(created.replyDispatch.intervention.messagePayload.linkPreviews[0].title, "Example Docs"); + assert.equal(created.replyDispatch.intervention.messagePayload.linkPreviews[0].materializationStatus, "metadata_only"); + assert.match(created.replyDispatch.intervention.messagePayload.linkPreviews[0].previewText, /Cached link-preview metadata only/); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].path, "README.md"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].rootRelation, "inside_workspace"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].kind, "file"); + assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].materializationStatus, "metadata_only"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("ling agent coordinator treats redirect-like text as queued guidance", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-redirect-")); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent Coordinator Redirect", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const run = createActiveChannelReplyRunFixture(store, { + cwd, + channelId: channel.id, + runId: "run_ling_agent_coordinator_redirect", + taskId: "task_ling_agent_coordinator_redirect", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "停,改用 daemon API", + replyDisposition: { mode: "queue_followup", activeRunId: run.id }, + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.replyDispatch.disposition, "followup_queued"); + assert.equal(created.replyDispatch.sourceRunId, run.id); + assert.equal(created.replyDispatch.intervention.mode, "queue_followup"); + assert.equal(created.replyDispatch.intervention.instruction, "停,改用 daemon API"); + assert.equal(store.getRun(run.id).status, "running"); + assert.deepEqual(store.listRunInterventions({ sourceRunId: run.id }).map((item) => item.id), [created.replyDispatch.intervention.id]); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel reply guidance drains FIFO without interrupting and projects every continuation reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-fifo-")); + await ensureGitRepo(cwd); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + const designerProvider = (await createStaticChatProvider(daemon.url, cwd, { + displayName: "Designer Static Chat", + providerId: "designer-static-chat", + model: "designer-static-model", + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await postJson(`${daemon.url}/providers/${encodeURIComponent(designerProvider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Channel reply guidance FIFO", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read"], + })).channel; + const designerId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(designerId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: designerProvider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(designerId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + + const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Start a delayed Ling reply.", + requestAiReply: true, + teammateId: "ling_agent", + providerPresetId: provider.id, + }); + assert.equal(source.aiReply.status, "pending"); + await waitForCondition(() => runner.runs.length === 1, "source channel reply to start"); + const sourceRun = store.listRuns({ limit: 20 }) + .find((run) => run.channelId === channel.id && !run.parentRunId); + assert.ok(sourceRun); + const sourceMessage = getChannelItemViewFixture(store, sourceRun.requestSnapshot.sourceMessageId); + assert.ok(sourceMessage); + assert.equal(findActiveChannelReplyRun({ + store, + channelId: channel.id, + sourceMessage, + activeRunId: sourceRun.id, + })?.id, sourceRun.id); + + const queuedInputs = [ + { + body: "@Designer queued channel guidance one.", + mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], + targetTeammateId: designerId, + }, + { + body: "Queued channel guidance two.", + mentions: [], + targetTeammateId: "ling_agent", + }, + ]; + const queued = []; + for (const queuedInput of queuedInputs) { + const response = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: queuedInput.body, + mentions: queuedInput.mentions, + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, + }); + assert.equal(response.replyDispatch.disposition, "followup_queued"); + assert.equal(response.replyDispatch.targetTeammateId, queuedInput.targetTeammateId); + assert.equal(response.replyDispatch.intervention.messagePayload.targetTeammateId, queuedInput.targetTeammateId); + queued.push(response); + } + assert.equal(runner.runs.length, 1); + assert.equal(runner.abortCount, 0); + const queuedMessageIds = new Set(queued.map((response) => response.message.id)); + const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual(whileQueued.messages.filter((message) => queuedMessageIds.has(message.id)), []); + + runner.resolve("Initial channel reply"); + await waitForCondition(() => runner.runs.length === 2, "first channel continuation to start"); + assert.match(runner.runs[1].task.prompt, /@Designer queued channel guidance one\./); + assert.equal(readCurrentChannelUserMessage(runner.runs[1].task.prompt), queuedInputs[0].body); + await waitForCondition( + () => store.getRunIntervention(queued[0].replyDispatch.intervention.id)?.continuationRunId, + "first channel continuation to link", + ); + const firstContinuation = store.getRun( + store.getRunIntervention(queued[0].replyDispatch.intervention.id).continuationRunId, + ); + assert.equal(firstContinuation.teammateId, designerId); + assert.equal(firstContinuation.providerPresetId, designerProvider.id); + assert.doesNotMatch(runner.runs[1].task.prompt, /Continue a Ling Stack supervised run from preserved context\./); + assert.match(runner.runs[1].systemPrompt, /Treat the latest queued follow-up as the current user message/); + const whileFirstRuns = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual( + whileFirstRuns.messages.filter((message) => queuedMessageIds.has(message.id)).map((message) => message.id), + [queued[0].message.id], + ); + runner.resolve("First channel continuation reply"); + + await waitForCondition(() => runner.runs.length === 3, "second channel continuation to start"); + assert.match(runner.runs[2].task.prompt, /Queued channel guidance two\./); + assert.equal(readCurrentChannelUserMessage(runner.runs[2].task.prompt), queuedInputs[1].body); + await waitForCondition( + () => store.getRunIntervention(queued[1].replyDispatch.intervention.id)?.continuationRunId, + "second channel continuation to link", + ); + const secondContinuation = store.getRun( + store.getRunIntervention(queued[1].replyDispatch.intervention.id).continuationRunId, + ); + assert.equal(secondContinuation.teammateId, "ling_agent"); + assert.equal(secondContinuation.providerPresetId, provider.id); + assert.doesNotMatch(runner.runs[2].task.prompt, /Continue a Ling Stack supervised run from preserved context\./); + assert.match(runner.runs[2].systemPrompt, /Treat the latest queued follow-up as the current user message/); + assert.match( + runner.runs[2].task.prompt, + /Conversation context selected by Continuity v2[\s\S]*First channel continuation reply/, + ); + runner.resolve("Second channel continuation reply"); + + await waitForCondition( + () => store.listRunInterventions({ sourceRunId: queued[0].replyDispatch.sourceRunId }).every((item) => item.completedAt), + "channel guidance interventions to complete", + ); + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + const visibleAiBodies = listed.messages + .filter((message) => message.authorType === "ai-teammate" && message.status === "ready") + .map((message) => message.body); + assert.deepEqual(visibleAiBodies, [ + "Initial channel reply", + "First channel continuation reply", + "Second channel continuation reply", + ]); + for (const [index, response] of queued.entries()) { + const intervention = store.getRunIntervention(response.replyDispatch.intervention.id); + assert.equal(intervention.status, "started"); + assert.ok(intervention.completedAt); + assert.equal(intervention.messagePayload.sourceMessageId, response.message.id); + const continuationRun = store.getRun(intervention.continuationRunId); + const continuationWorkUnit = store.getWorkUnit(intervention.continuationWorkUnitId); + assert.equal(continuationRun.status, "succeeded"); + assert.equal(continuationWorkUnit.ownerId, response.message.id); + assert.ok(continuationWorkUnit.linkedMessageId); + const reply = getChannelItemViewFixture(store, continuationWorkUnit.linkedMessageId); + assert.equal(reply.body, `${index === 0 ? "First" : "Second"} channel continuation reply`); + assert.equal(reply.linkedRunId, continuationRun.id); + } + assert.equal(runner.abortCount, 0); + } finally { + for (let index = 0; index < 3; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel reply guidance continues draining after the first continuation fails to start", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-start-failure-")); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Channel reply guidance start failure", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + + await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Start a delayed reply before draining guidance.", + requestAiReply: true, + providerPresetId: provider.id, + }); + await waitForCondition(() => runner.runs.length === 1, "source reply to start before failure drain"); + const sourceRun = store.listRuns({ limit: 20 }).find((run) => run.channelId === channel.id && !run.parentRunId); + assert.ok(sourceRun); + + const invalid = store.createRunIntervention({ + sourceRunId: sourceRun.id, + mode: "queue_followup", + instruction: "This source message is intentionally missing.", + messagePayload: { + sourceMessageId: "msg_missing_guidance_source", + targetTeammateId: "ling_agent", + body: "This source message is intentionally missing.", + attachmentIds: [], + linkPreviewIds: [], + }, + idempotencyKey: "channel_message:msg_missing_guidance_source:queue_followup", + requestedBy: "local-user", + }, "2026-08-08T00:00:00.000Z"); + const valid = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "This second queued message must still run.", + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, + }); + assert.equal(valid.replyDispatch.disposition, "followup_queued"); + + runner.resolve("Initial reply before failure drain"); + await waitForCondition( + () => store.getRunIntervention(invalid.id)?.status === "failed", + "invalid first guidance to fail", + ); + await waitForCondition(() => runner.runs.length === 2, "second guidance to start after first failure"); + assert.match(runner.runs[1].task.prompt, /This second queued message must still run\./); + const validInterventionId = valid.replyDispatch.intervention.id; + await waitForCondition( + () => Boolean(store.getRunIntervention(validInterventionId)?.continuationRunId), + "second guidance continuation to link after first failure", + ); + assert.equal(store.getRunIntervention(invalid.id)?.failureReason.includes("invalid"), true); + assert.equal(store.getRunIntervention(validInterventionId)?.status, "started"); + runner.resolve("Second guidance completed"); + } finally { + for (let index = 0; index < 3; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("queued channel source stays hidden while dispatch classification is still in flight", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-classification-visibility-")); + await ensureGitRepo(cwd); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + let releaseVision; + let visionStartedCount = 0; + const visionRelease = new Promise((resolve) => { + releaseVision = resolve; + }); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + imageVisionSummarizer: async () => { + visionStartedCount += 1; + await visionRelease; + return { + contentText: "A screenshot used to hold dispatch classification open.", + structuredSummary: { source: "classification_visibility_test" }, + }; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Classification visibility", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read"], + })).channel; + const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Start a delayed reply before classification visibility is tested.", + requestAiReply: true, + teammateId: "ling_agent", + providerPresetId: provider.id, + }); + assert.equal(source.aiReply.status, "pending"); + await waitForCondition(() => runner.runs.length === 1, "source channel reply to start"); + + const store = stores.get(cwd); + assert.ok(store); + const sourceRun = store.listRuns({ limit: 20 }) + .find((run) => run.channelId === channel.id && run.requestSnapshot.sourceMessageId === source.message.id); + assert.ok(sourceRun); + await mkdir(store.paths.attachmentsDir, { recursive: true }); + await writeFile(resolve(store.paths.attachmentsDir, "classification.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + const attachment = store.createChannelAttachment({ + channelId: channel.id, + kind: "image", + fileName: "classification.png", + contentType: "image/png", + sizeBytes: 8, + path: "classification.png", + createdAt: new Date().toISOString(), + }); + const queuedBody = "Queue this message while dispatch classification is blocked."; + const streamResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: queuedBody, + attachmentIds: [attachment.id], + requestAiReply: true, + replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, + }), + }); + assert.equal(streamResponse.status, 200); + await waitForCondition(() => visionStartedCount === 1, "classification image summarizer to start"); + + const whileClassifying = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.equal(whileClassifying.messages.some((message) => message.body === queuedBody), false); + + releaseVision(); + const events = parseSseEvents(await streamResponse.text()); + const terminal = events.at(-1); + assert.equal(terminal.event, "done"); + assert.equal(terminal.data.replyDispatch.disposition, "followup_queued"); + const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.equal(whileQueued.messages.some((message) => message.id === terminal.data.message.id), false); + } finally { + releaseVision?.(); + for (let index = 0; index < 3; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("unmentioned channel guidance joins the pending Ling reply lane before its run is linked", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-pending-lane-")); + await ensureGitRepo(cwd); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + let webSearchCallCount = 0; + let releaseWebSearch; + const webSearchRelease = new Promise((resolve) => { + releaseWebSearch = resolve; + }); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + secretResolver: createCompositeSecretResolver({ env: { OPENAI_API_KEY: "sk-test-secret-value" } }), + channelWebFetch: async () => { + webSearchCallCount += 1; + await webSearchRelease; + return new Response(JSON.stringify({ + output_text: "Delayed web evidence.", + output: [{ + type: "web_search_call", + action: { + type: "search", + sources: [{ type: "url", url: "https://example.com/delayed", title: "Delayed source" }], + }, + }], + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + kind: "openai", + displayName: "Delayed web reply", + providerId: "openai", + model: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + apiKeyRef: "env:OPENAI_API_KEY", + authHeader: true, + capabilities: ["chat", "tool_call", "reasoning", "web_search"], + })).provider; + const store = stores.get(cwd); + assert.ok(store); + store.recordProviderCheckResult(provider.id, { + ok: true, + code: "static_check", + message: "Provider ready for pending reply lane test.", + checkedAt: new Date().toISOString(), + }); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + + const sourcePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "查一下最新版本说明。", + requestAiReply: true, + providerPresetId: provider.id, + }); + await waitForCondition(() => webSearchCallCount === 1, "source reply web search to start"); + const guidancePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "After that, reply only with pending lane guidance.", + requestAiReply: true, + providerPresetId: provider.id, + replyDisposition: { mode: "queue_followup" }, + }); + + releaseWebSearch(); + await waitForCondition(() => runner.runs.length === 1, "source Ling reply run to start"); + const sourceRun = store.listRuns({ teammateId: "ling_agent", limit: 20 }) + .find((run) => run.channelId === channel.id && !run.parentRunId); + assert.ok(sourceRun); + assert.equal(sourceRun.teammateId, "ling_agent"); + assert.ok(sourceRun.workUnitId); + assert.equal(store.getWorkUnit(sourceRun.workUnitId).teammateId, "ling_agent"); + assert.equal(store.listRuns({ teammateId: "ai-teammate", limit: 20 }).length, 0); + runner.resolve("Initial pending lane reply"); + const source = await sourcePromise; + const guidance = await guidancePromise; + assert.equal(source.aiReply.status, "pending"); + assert.equal(source.aiReply.authorId, "ling_agent"); + assert.equal(guidance.aiReply, undefined); + assert.equal(guidance.replyDispatch.disposition, "followup_queued"); + assert.equal(guidance.replyDispatch.targetTeammateId, "ling_agent"); + assert.equal(guidance.replyDispatch.intervention.mode, "queue_followup"); + assert.equal(guidance.replyDispatch.intervention.messagePayload.sourceMessageId, guidance.message.id); + assert.equal(runner.runs.length, 1); + assert.equal(runner.abortCount, 0); + + await waitForCondition(() => runner.runs.length === 2, "pending lane continuation to start"); + assert.equal(readCurrentChannelUserMessage(runner.runs[1].task.prompt), guidance.message.body); + runner.resolve("Pending lane guidance reply"); + await waitForCondition(() => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.completedAt, "pending lane guidance to complete"); + assert.equal(runner.abortCount, 0); + } finally { + releaseWebSearch?.(); + for (let index = 0; index < 4; index += 1) { + runner.resolve("cleanup"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel messages persist and AI blocker replies do not drop the user message", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-messages-")); + const daemon = await startDaemonForTest(); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultTeammateId: "engineer", + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "请 @Engineer 完善频道需求", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + assert.equal(created.message.body, "请 @Engineer 完善频道需求"); + assert.equal(created.aiReply.status, "blocked"); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); + assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("known unavailable teammate is accepted and replayed as one typed Channel blocker", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const teammateId = "teammate_builtin_engineer"; + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + status: "paused", + }); + const body = { + body: "@Engineer 请执行这个任务", + mentions: [], + requestAiReply: true, + idempotencyKey: "unavailable-participant-replay", + }; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, body); + assert.equal(created.message.mentions[0].id, teammateId); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, `participant_unavailable_in_channel:${teammateId}`); + assert.match(created.aiReply.body, /当前无法在此频道参与/); + + const replay = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, body); + assert.equal(replay.message.id, created.message.id); + assert.equal(replay.aiReply.id, created.aiReply.id); + assert.equal(runner.runs.length, 0); + + const store = stores.get(cwd); + assert.ok(store); + const sourceItem = store.getConversationItem(created.message.id); + assert.ok(sourceItem?.turnId); + const dispatch = store.findWorkUnitByIdempotency({ + kind: "channel_turn_dispatch", + ownerType: "ling_turn", + ownerId: sourceItem.turnId, + idempotencyKey: `channel-turn-dispatch:${sourceItem.turnId}`, + }); + assert.deepEqual(dispatch.request.unavailableRequestedParticipantIds, [teammateId]); + assert.deepEqual(sourceItem.payload.mentions.map((mention) => mention.id), [teammateId]); + assert.equal(dispatch.status, "succeeded"); + assert.equal(store.getLingTurn(sourceItem.turnId).status, "blocked"); + const turnTransitions = await getJson( + `${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=turn&entityId=${encodeURIComponent(sourceItem.turnId)}`, + ); + assert.equal(turnTransitions.transitions.every((transition) => transition.entityType === "turn"), true); + assert.equal(turnTransitions.transitions.every((transition) => transition.entityId === sourceItem.turnId), true); + assert.equal(turnTransitions.transitions.some((transition) => transition.toStatus === "blocked"), true); + assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) + .filter((item) => item.kind === "assistant_message").length, 1); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("unavailable participant produces the same typed blocker over Channel SSE", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-sse-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const teammateId = "teammate_builtin_engineer"; + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + hidden: true, + }); + const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: "@Engineer 请检查这个任务", + mentions: [], + requestAiReply: true, + }), + }); + assert.equal(response.status, 200); + const events = parseSseEvents(await response.text()); + assert.deepEqual(events.map((event) => event.event), ["ready", "blocked"]); + assert.equal(events.at(-1).data.aiReply.status, "blocked"); + assert.equal(events.at(-1).data.aiReply.errorMessage, `participant_unavailable_in_channel:${teammateId}`); + assert.deepEqual(events[0].data.message.mentions.map((mention) => mention.id), [teammateId]); + assert.equal(runner.runs.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel discussion uses the mentioned teammate provider preset", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-teammate-provider-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + const check = await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + assert.equal(check.ok, true); + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer 你好", + mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, teammateId); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Designer reply"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); + assert.match(runner.runs[0].task.prompt, /Response language: follow_user \(source=default\)/); + assert.match(runner.runs[0].task.prompt, /Current teammate: Designer \(id: teammate_builtin_designer, role: designer\)/); + assert.doesNotMatch(runner.runs[0].task.prompt, /Channel teammates \(configured roster/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("an untrusted scripted channel adapter cannot publish Provider availability incidents", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-provider-runtime-incident-")); + const rateLimitError = Object.assign(new Error("Too many requests"), { code: "rate_limited" }); + const runner = new ScriptedRunner([rateLimitError]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); + const store = stores.get(cwd); + assert.ok(store); + const availability = store.listAvailabilityIncidents({ + providerPresetId: provider.id, + now: new Date().toISOString(), + limit: 10, + }); + assert.deepEqual(availability, [], "adapter error text is not an admitted SDK Provider failure fact"); + assert.deepEqual(store.listReliabilityIncidents({ agentId: teammateId, capabilityScope: "code", limit: 10 }), []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel observer bus stays inert when the feature flag is disabled", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-off-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { teamObserverBus: false } }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready", created.message.threadId); + const store = stores.get(cwd); + assert.ok(store); + assert.deepEqual(store.listChannelTeamEvidenceItems({ channelId: channel.id, limit: 10 }), []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel observer bus writes bounded relevance evidence without visible observer messages", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-on-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { teamObserverBus: true } }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); + const store = stores.get(cwd); + assert.ok(store); + const evidence = store.listChannelTeamEvidenceItems({ + channelId: channel.id, + turnId: created.message.id, + kind: "agent_relevance_card", + limit: 10, + }); + assert.equal(evidence.length, 1); + assert.equal(evidence[0].agentId, teammateId); + assert.equal(evidence[0].visibility, "internal"); + assert.equal(evidence[0].status, "completed"); + assert.equal(evidence[0].sourceRefs.some((sourceRef) => sourceRef.type === "message" && sourceRef.id === created.message.id), true); + assert.match(evidence[0].summary, /Engineer/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel observer bus skips unavailable observers without blocking the main reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-unavailable-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { teamObserverBus: true } }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + store.createAvailabilityIncident( + { + id: "incident_observer_engineer_overloaded", + agentId: teammateId, + providerPresetId: provider.id, + state: "overloaded", + kind: "overload", + severity: "medium", + summary: "Observer provider is temporarily overloaded.", + observedAt: "2099-06-27T00:00:00.000Z", + expiresAt: "2099-06-28T00:10:00.000Z", + }, + "2099-06-27T00:00:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); + assert.deepEqual( + store.listChannelTeamEvidenceItems({ + channelId: channel.id, + turnId: created.message.id, + kind: "agent_relevance_card", + limit: 10, + }), + [], + ); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel observer bus applies provider-level availability incidents to matching observers", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-unavailable-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { teamObserverBus: true } }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + store.createAvailabilityIncident( + { + id: "incident_provider_rate_limited", + providerPresetId: provider.id, + state: "rate_limited", + kind: "quota", + severity: "medium", + summary: "Provider quota is temporarily exhausted.", + observedAt: "2099-06-27T00:00:00.000Z", + expiresAt: "2099-06-28T00:10:00.000Z", + }, + "2099-06-27T00:00:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + assert.deepEqual( + store.listChannelTeamEvidenceItems({ + channelId: channel.id, + turnId: created.message.id, + kind: "agent_relevance_card", + limit: 10, + }), + [], + ); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel observer bus ignores expired provider-level availability incidents", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-expired-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { teamObserverBus: true } }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + store.createAvailabilityIncident( + { + id: "incident_provider_expired_outage", + providerPresetId: provider.id, + state: "offline", + kind: "network", + severity: "medium", + summary: "Provider network outage already expired.", + observedAt: "2026-06-26T00:00:00.000Z", + expiresAt: "2026-06-26T00:10:00.000Z", + }, + "2026-06-26T00:00:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 看一下这个实现边界", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const evidence = store.listChannelTeamEvidenceItems({ + channelId: channel.id, + turnId: created.message.id, + kind: "agent_relevance_card", + limit: 10, + }); + assert.equal(evidence.length, 1); + assert.equal(evidence[0].agentId, teammateId); + assert.equal(evidence[0].status, "completed"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel discussion injects web search context before runner reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-web-search-context-")); + const runner = new RecordingRunner(); + const webSearchCalls = []; + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + secretResolver: createCompositeSecretResolver({ env: { OPENAI_API_KEY: "sk-test-secret-value" } }), + channelWebFetch: async (url, init) => { + webSearchCalls.push({ url: String(url), init }); + return new Response(JSON.stringify({ + output_text: "Fresh web evidence summary.", + output: [ + { + type: "web_search_call", + action: { + type: "search", + sources: [ + { type: "url", url: "https://example.com/fresh", title: "Fresh source" }, + ], + }, + }, + ], + }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + kind: "openai", + displayName: "OpenAI Responses + Web Search", + providerId: "openai", + model: "gpt-5.5", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + apiKeyRef: "env:OPENAI_API_KEY", + authHeader: true, + capabilities: ["chat", "tool_call", "reasoning", "web_search"], + })).provider; + const store = stores.get(cwd); + assert.ok(store); + store.recordProviderCheckResult(provider.id, { + ok: true, + code: "static_check", + message: "Provider ready for web search context test.", + checkedAt: new Date().toISOString(), + }); + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer 查一下 Ling Stack 最新消息", + mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Designer reply"); + assert.equal(finalReply.webEvidence.status, "available"); + assert.equal(finalReply.webEvidence.query, "查一下 Ling Stack 最新消息"); + assert.equal(finalReply.webEvidence.provider.adapterId, "openai-responses-web-search"); + assert.deepEqual(finalReply.webEvidence.sources.map((source) => source.url), ["https://example.com/fresh"]); + assert.doesNotMatch(JSON.stringify(finalReply.webEvidence), /sk-test-secret-value/); + const workUnit = store.listWorkUnits({ kind: "channel_ai_reply", linkedMessageId: finalReply.id, limit: 1 })[0]; + assert.ok(workUnit); + assert.ok(finalReply.linkedRunId); + const run = store.getRun(finalReply.linkedRunId); + assert.ok(run); + assert.equal(run.status, "succeeded"); + assert.equal(run.workUnitId, workUnit.id); + assert.equal(workUnit.linkedRunId, run.id); + assert.equal(workUnit.linkedMessageId, finalReply.id); + assert.equal(store.getLingThread(run.threadId)?.kind, "channel_discussion"); + assert.equal(store.listWorkUnits({ kind: "channel_ai_reply", linkedMessageId: finalReply.id, limit: 10 }).length, 1); + const webEvidenceEvents = store.listRuntimeEvents(workUnit.id).filter((event) => event.type === "web_evidence_recorded"); + assert.equal(webEvidenceEvents.length, 1); + assert.equal(webEvidenceEvents[0].data.toolSurface, "web"); + assert.equal(webEvidenceEvents[0].data.sourceCount, 1); + assert.deepEqual(webEvidenceEvents[0].data.sources.map((source) => source.url), ["https://example.com/fresh"]); + const webProgressEvents = store.listRuntimeEvents(workUnit.id).filter((event) => event.type === "work_unit_progress" && event.data.toolSurface === "web"); + assert.deepEqual(webProgressEvents.map((event) => event.data.type), [ + "web_search_planned", + "web_search_started", + "web_search_completed", + ]); + assert.equal(webProgressEvents[2].data.status, "available"); + assert.equal(webProgressEvents[2].data.sourceCount, 1); + assert.equal(webSearchCalls.length, 1); + assert.equal(webSearchCalls[0].url, "https://api.openai.com/v1/responses"); + const requestBody = JSON.parse(webSearchCalls[0].init.body); + assert.deepEqual(requestBody.tools, [{ type: "web_search", search_context_size: "low" }]); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Ling web context:/); + assert.match(runner.runs[0].task.prompt, /Fresh web evidence summary/); + assert.match(runner.runs[0].task.prompt, /https:\/\/example\.com\/fresh/); + assert.match(runner.runs[0].systemPrompt, /explain the limitation briefly in user-facing language/); + assert.match(runner.runs[0].systemPrompt, /do not repeat provider, relay, or protocol diagnostics verbatim/); + assert.doesNotMatch(runner.runs[0].task.prompt, /sk-test-secret-value/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel discussion blocks runner prompt envelope echoes", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-prompt-echo-")); + const runner = new PromptEchoRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer 给我一片关于AI发展的分析报告", + mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.match(finalReply.body, /Runner echoed internal channel context/); + assert.doesNotMatch(finalReply.body, /Channel collaboration protocol:/); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel message stream emits visible deltas into a durable AI reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-")); + const runner = new ScriptedRunner(["Streamed channel reply"]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: "@Engineer 请流式回复", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }), + }); + + assert.equal(response.status, 200); + const events = parseSseEvents(await response.text()); + assert.deepEqual(events.map((event) => event.event), ["ready", "reply_pending", "ready", "run_linked", "delta", "done"]); + assert.equal(events[0].data.message.body, "@Engineer 请流式回复"); + assert.equal(events[1].data.sourceItemId, events[0].data.message.id); + assert.equal(events[1].data.targetTeammateId, "teammate_builtin_engineer"); + assert.ok(events[1].data.startedAt); + assert.equal(events[2].data.aiReply.status, "pending"); + assert.equal(events[3].data.itemId, events[2].data.aiReply.id); + assert.ok(events[3].data.linkedRunId); + const { threadId, sourceMessageId, ...activeReplyWithoutLineage } = events[3].data.activeReply; + assert.deepEqual(activeReplyWithoutLineage, { + runId: events[3].data.linkedRunId, + sourceRunId: events[3].data.linkedRunId, + workUnitId: events[3].data.activeReply.workUnitId, + channelId: channel.id, + teammateId: "teammate_builtin_engineer", + status: "queued", + createdAt: events[3].data.activeReply.createdAt, + updatedAt: events[3].data.activeReply.updatedAt, + }); + assert.match(threadId, /^thread_/); + assert.equal(sourceMessageId, events[0].data.message.id); + assert.equal(threadId, events[2].data.aiReply.threadId); + assert.ok(events[3].data.activeReply.workUnitId); + assert.equal(JSON.stringify(events[3].data.activeReply).includes("prompt"), false); + assert.equal(events[4].data.delta, "Streamed channel reply"); + assert.equal(events[4].data.body, "Streamed channel reply"); + assert.equal(events[5].data.aiReply.body, "Streamed channel reply"); + + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + const finalReply = listed.messages.find((message) => message.id === events[2].data.aiReply.id); + assert.equal(finalReply.body, "Streamed channel reply"); + assert.equal(finalReply.status, "ready"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("conversation stream materializes uploaded attachments into the run prompt", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-conversation-attachment-")); + const runner = new ScriptedRunner(["Attachment received"]); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { + lingConversationTone: "friendly", + }); + const attachment = (await postJson(`${daemon.url}/conversations/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "notes.txt", + contentBase64: Buffer.from("Alpha decision: keep the shared composer contract.").toString("base64"), + })).attachment; + + const response = await fetch(`${daemon.url}/conversations/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: "Summarize the attached note.", + attachmentIds: [attachment.id], + providerPresetId: provider.id, + authorizationMode: "smart", + workspaceAccessMode: "workspace_write", + }), + }); + + assert.equal(response.status, 200); + const events = parseSseEvents(await response.text()); + const initialReady = events.find((event) => event.event === "ready" && event.data.userMessage); + assert.ok(initialReady); + assert.equal(initialReady.data.userMessage.attachments[0].id, attachment.id); + assert.equal(initialReady.data.userMessage.attachments[0].fileName, "notes.txt"); + const linkedReady = events.find((event) => event.event === "ready" && event.data.activeReply); + assert.ok(linkedReady); + assert.equal(linkedReady.data.activeReply.runId, linkedReady.data.runId); + assert.equal(linkedReady.data.activeReply.sourceRunId, linkedReady.data.runId); + assert.equal(linkedReady.data.activeReply.threadId, linkedReady.data.thread.id); + assert.equal(linkedReady.data.activeReply.teammateId, "ling_agent"); + assert.equal(linkedReady.data.activeReply.status, "queued"); + assert.ok(linkedReady.data.activeReply.workUnitId); + assert.equal(JSON.stringify(linkedReady.data.activeReply).includes("requestSnapshot"), false); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Attached files:/); + assert.match(runner.runs[0].task.prompt, /Alpha decision: keep the shared composer contract\./); + assert.doesNotMatch(runner.runs[0].task.prompt, /Use English for user-visible output/); + assert.ok(runner.runs[0].appendSystemPrompt.some((instruction) => /Default conversation tone: friendly\./.test(instruction))); + assert.ok(runner.runs[0].appendSystemPrompt.some((instruction) => /Match the language of the most recent user message/.test(instruction))); + + const done = events.find((event) => event.event === "done"); + assert.ok(done); + assert.equal(done.data.run.requestSnapshot.lingConversationTone, "friendly"); + assert.equal(done.data.run.requestSnapshot.authorizationMode, "smart"); + assert.equal(done.data.run.requestSnapshot.workspaceAccessMode, "workspace_write"); + assert.equal(done.data.run.requestSnapshot.responseLanguage, "en-US"); + // System instructions are retained in the Runner input above but are + // intentionally omitted from the public Run snapshot. + assert.equal(done.data.run.requestSnapshot.appendSystemPrompt, undefined); + + const detail = await getJson(`${daemon.url}/conversations/${encodeURIComponent(initialReady.data.thread.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(detail.defaults.authorizationMode, "smart"); + assert.equal(detail.defaults.workspaceAccessMode, "workspace_write"); + const userMessage = detail.messages.find((message) => message.id === initialReady.data.userMessage.id); + assert.equal(userMessage.attachments[0].id, attachment.id); + assert.deepEqual(detail.executionActions, []); + + const preview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(attachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=conversation&threadId=${encodeURIComponent(initialReady.data.thread.id)}`); + assert.equal(preview.kind, "plain_text"); + assert.match(preview.text, /Alpha decision/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel message stream hides unbound task proposal blocks without creating approvals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-proposal-")); + const runner = new ScriptedRunner([ + `可以开始。\n{"title":"Stream proposal","prompt":"Implement streaming","assigneeTeammateId":"teammate_builtin_engineer","acceptanceCriteria":["reply streams"],"collaborationSteps":[{"teammateId":"teammate_builtin_engineer","action":"coordinated","summary":"Implement"}],"requiredTools":["read"],"riskSummary":"low"}`, + ]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: "@Engineer 可以开始实现吗", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }), + }); + + assert.equal(response.status, 200); + const events = parseSseEvents(await response.text()); + assert.deepEqual(events.map((event) => event.event), ["ready", "reply_pending", "ready", "run_linked", "delta", "done"]); + assert.doesNotMatch(JSON.stringify(events.map((event) => event.data)), /ling_task_proposal/); + assert.equal(events[4].data.body, "可以开始。\n"); + assert.equal(events[5].data.aiReply.body, "可以开始。"); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); + assert.equal(proposals.proposals.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel AI reply prompt carries projected segment context for short follow-ups", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-followup-context-")); + const runner = new ScriptedRunner([ + "雨下到第三天,城里的钟停了。修表匠阿岚把怀表放进钟芯,世界重新开始走动。", + "四百字版本", + ]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "300字的小说", + providerPresetId: provider.id, + teammateId: "ling_agent", + requestAiReply: true, + }); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, first.aiReply.id, "ready"); + + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "400字", + providerPresetId: provider.id, + teammateId: "ling_agent", + requestAiReply: true, + }); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, second.aiReply.id, "ready"); + + assert.equal(runner.runs.length, 2); + assert.equal(runner.segmentationRuns.length, 1); + assert.match(runner.runs[1].task.prompt, /Conversation context selected by Continuity v2/); + assert.match(runner.runs[1].task.prompt, /300字的小说/); + assert.match(runner.runs[1].task.prompt, /雨下到第三天,城里的钟停了/); + assert.match(runner.runs[1].task.prompt, /User message:[\s\S]*400字/); + assert.doesNotMatch(runner.runs[1].task.prompt, /AI teammate is preparing a reply/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel AI reply injects Plan Mode instructions for plan threads", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-plan-mode-prompt-")); + const runner = new ScriptedRunner(["1. Inspect first."]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const thread = await postJson(`${daemon.url}/threads?cwd=${encodeURIComponent(cwd)}`, { + kind: "channel_discussion", + channelId: channel.id, + }); + await patchJson(`${daemon.url}/threads/${encodeURIComponent(thread.thread.id)}/mode?cwd=${encodeURIComponent(cwd)}`, { + mode: "plan", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Plan the next implementation slice.", + surface: { kind: "thread", threadId: thread.thread.id }, + providerPresetId: provider.id, + teammateId: "ling_agent", + requestAiReply: true, + }); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready", created.message.threadId); + + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].systemPrompt, /Plan Mode is active/); + assert.match(runner.runs[0].systemPrompt, //); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel no-tools AI reply retries once with compacted channel summary after context-length error", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-context-retry-")); + const runner = new ScriptedRunner([ + new Error("context_length exceeded by test provider"), + "Retry reply", + ]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer 请根据频道上下文回复", + mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], + requestAiReply: true, + }); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const reply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(reply.body, "Retry reply"); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[1].task.prompt, /Compacted channel discussion summary for a no-tools retry/); + assert.match(runner.runs[1].task.prompt, /channel_discussion_summary|Compacted visible message count/i); + const downgradedBudget = resolveProviderContextBudget(provider, "channel_reply"); + assert.notEqual(downgradedBudget.capabilitySource, "runtime_observed", + "scripted error text permits a bounded reply retry, not Provider window learning"); + assert.equal(downgradedBudget.contextWindowTokens, 32768); + const providerList = await getJson(`${daemon.url}/providers?cwd=${encodeURIComponent(cwd)}`); + const persistedProvider = providerList.providers.find((item) => item.id === provider.id); + assert.ok(persistedProvider); + assert.equal(persistedProvider.contextWindow, undefined); + assert.equal(persistedProvider.maxTokens, undefined); + + const summaries = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`); + assert.equal(summaries.contextSummaries.length, 0); + const store = stores.get(cwd); + assert.ok(store); + const incidents = store.listAvailabilityIncidents({ + providerPresetId: provider.id, + now: new Date().toISOString(), + limit: 10, + }); + assert.deepEqual(incidents, [], "only the trusted SDK admission seam can publish runtime availability"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel hard summary uses the effective workspace provider before dispatching a reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proactive-summary-")); + const runner = new SummaryAwareRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + contextWindow: 1_800, + maxTokens: 512, + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Proactive summary", + objective: "Summarize before the provider rejects the prompt", + }, "2026-08-10T06:00:00.000Z").thread; + + for (let index = 0; index < 10; index += 1) { + const createdAt = new Date(Date.parse("2026-08-10T06:00:01.000Z") + index * 10_000).toISOString(); + const turn = store.appendConversationUserTurn({ + itemId: `item_proactive_summary_${index}`, + turnId: `turn_proactive_summary_${index}`, + channelId: channel.id, + threadId: thread.id, + title: `Historical fact ${index}`, + userInstruction: `Historical fact ${index}`, + payload: { + body: `Historical fact ${index}: ${"x".repeat(900)}`, + format: "plain_text", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + idempotencyKey: `proactive-summary:${index}`, + }, createdAt); + const run = store.createRun({ + id: `run_proactive_summary_${index}`, + cwd, + taskId: `task_proactive_summary_${index}`, + channelId: channel.id, + threadId: thread.id, + turnId: turn.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: { + id: `task_proactive_summary_${index}`, + title: `Historical fact ${index}`, + prompt: `Historical fact ${index}`, + }, + providerSnapshot: { provider: "test", model: "test" }, + requestSnapshot: { + cwd, + task: { + id: `task_proactive_summary_${index}`, + title: `Historical fact ${index}`, + prompt: `Historical fact ${index}`, + }, + }, + createdAt: new Date(Date.parse(createdAt) + 1_000).toISOString(), + }); + store.markRunStarted(run.id, new Date(Date.parse(createdAt) + 2_000).toISOString()); + store.markRunSucceeded(run.id, new Date(Date.parse(createdAt) + 3_000).toISOString()); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer continue from the durable summary.", + mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], + teammateId: "teammate_builtin_engineer", + requestAiReply: true, + }); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + + const target = resolveChannelReplyExecutionTarget({ + store, + channel, + requestedTeammateId: "teammate_builtin_engineer", + }); + assert.equal(target.providerPresetId, provider.id); + const currentItem = store.getConversationItem(created.message.id); + assert.ok(currentItem); + const plan = planConversationContextSummary({ + store, + sourceItemId: currentItem.id, + inputBudgetTokens: resolveProviderContextBudget(provider, "channel_reply").inputBudgetTokens, + }); + assert.equal(plan?.trigger, "hard"); + assert.equal(runner.summaryRuns.length, 1); + assert.equal(runner.replyRuns.length, 1); + const summaries = store.listConversationContextSummaries({ threadId: thread.id }); + assert.equal(summaries.length, 1); + assert.equal(summaries[0].objective, "Preserve the durable historical objective."); + assert.ok(summaries[0].coveredThroughSequence < currentItem.sequence); + assert.match(runner.replyRuns[0].task.prompt, /Preserve the durable historical objective/); + const dispatch = store.findWorkUnitByIdempotency({ + kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: currentItem.turnId, + idempotencyKey: `channel-turn-dispatch:${currentItem.turnId}`, + }); + assert.ok(dispatch); + const summaryEvents = store.listRuntimeEvents(dispatch.id) + .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); + assert.deepEqual(summaryEvents.map((event) => event.data.status), ["completed"]); + assert.equal(summaryEvents[0].attemptId, dispatch.activeAttemptId); + assert.equal(summaryEvents[0].data.sourceItemId, currentItem.id); + + runner.failSummary = true; + for (let index = 10; index < 20; index += 1) { + const createdAt = new Date(Date.parse("2026-08-10T06:02:00.000Z") + index * 10_000).toISOString(); + const turn = store.appendConversationUserTurn({ + itemId: `item_proactive_summary_${index}`, + turnId: `turn_proactive_summary_${index}`, + channelId: channel.id, + threadId: thread.id, + title: `Historical fact ${index}`, + userInstruction: `Historical fact ${index}`, + payload: { + body: `Historical fact ${index}: ${"x".repeat(900)}`, + format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [], + }, + idempotencyKey: `proactive-summary:${index}`, + }, createdAt); + const run = store.createRun({ + id: `run_proactive_summary_${index}`, + cwd, + taskId: `task_proactive_summary_${index}`, + channelId: channel.id, + threadId: thread.id, + turnId: turn.turnId, + providerName: "test", + providerModel: "test", + taskSnapshot: { id: `task_proactive_summary_${index}`, title: `Historical fact ${index}`, prompt: `Historical fact ${index}` }, + providerSnapshot: { provider: "test", model: "test" }, + requestSnapshot: { cwd, task: { id: `task_proactive_summary_${index}`, title: `Historical fact ${index}`, prompt: `Historical fact ${index}` } }, + createdAt: new Date(Date.parse(createdAt) + 1_000).toISOString(), + }); + store.markRunStarted(run.id, new Date(Date.parse(createdAt) + 2_000).toISOString()); + store.markRunSucceeded(run.id, new Date(Date.parse(createdAt) + 3_000).toISOString()); + } + const failedSummaryTurn = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer continue with the retained facts after summary failure.", + mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], + teammateId: "teammate_builtin_engineer", + requestAiReply: true, + }); + const failedSource = store.getConversationItem(failedSummaryTurn.message.id); + assert.ok(failedSource); + const failedDispatch = store.findWorkUnitByIdempotency({ + kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: failedSource.turnId, + idempotencyKey: `channel-turn-dispatch:${failedSource.turnId}`, + }); + assert.ok(failedDispatch); + const failedEvents = store.listRuntimeEvents(failedDispatch.id) + .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); + assert.deepEqual(failedEvents.map((event) => event.data.status), ["generation_failed"]); + assert.equal(failedEvents[0].data.fallback, "prepared_input_budget_gate"); + assert.equal(store.listConversationContextSummaries({ threadId: thread.id }).length, 1); + const listedFallback = await waitForChannelMessageStatus( + daemon.url, cwd, channel.id, failedSummaryTurn.aiReply.id, "ready", failedSummaryTurn.message.threadId, + ); + const fallbackReply = listedFallback.messages.find((message) => message.id === failedSummaryTurn.aiReply.id); + assert.equal(fallbackReply.body, "Reply after proactive summary."); + assert.equal(runner.replyRuns.length, 2); + const fallbackRun = runner.replyRuns[1]; + const preparedTokens = estimateTextTokens(fallbackRun.systemPrompt) + estimateTextTokens(fallbackRun.task.prompt); + assert.ok(preparedTokens <= resolveProviderContextBudget(provider, "channel_reply").inputBudgetTokens); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel hard summary with no completed prefix records the limitation and blocks oversized input", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-no-summary-prefix-")); + const runner = new SummaryAwareRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + contextWindow: 1_800, maxTokens: 512, + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `@Engineer analyze this full input: ${"x".repeat(12_000)}`, + mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], + teammateId: "teammate_builtin_engineer", + providerPresetId: provider.id, + requestAiReply: true, + }); + const listed = await waitForChannelMessageStatus( + daemon.url, cwd, channel.id, created.aiReply.id, "blocked", created.message.threadId, + ); + assert.match(listed.messages.find((message) => message.id === created.aiReply.id).body, /context budget/u); + const store = stores.get(cwd); + const source = store.getConversationItem(created.message.id); + const dispatch = store.findWorkUnitByIdempotency({ + kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: source.turnId, + idempotencyKey: `channel-turn-dispatch:${source.turnId}`, + }); + assert.ok(dispatch); + const events = store.listRuntimeEvents(dispatch.id) + .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); + assert.deepEqual(events.map((event) => event.data.status), ["no_eligible_prefix"]); + assert.equal(events[0].data.fallback, "prepared_input_budget_gate"); + assert.equal(runner.runs.length, 0); + assert.equal(store.listRuns({ limit: 10 }).length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/test/channel-intents.test.mjs b/packages/daemon/test/channel-intents.test.mjs new file mode 100644 index 000000000..93553125d --- /dev/null +++ b/packages/daemon/test/channel-intents.test.mjs @@ -0,0 +1,4678 @@ +import { appendChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; +import assert from "node:assert/strict"; +import { access, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createCompositeSecretResolver, createSqliteRunStore, projectConversationItemToChannelMessage } from "../../core/dist/index.js"; +import { createRequiredParticipantManifestV1, createWorkflowParticipantAuthorityV1 } from "../../contracts/dist/index.js"; +import { routeChannelMessageReply } from "../dist/features/channels/channel-message-router.js"; +import { resolveWorkflowParticipantRunProfile } from "../dist/features/channels/workflow-participant-run-profile.js"; +import { preflightWorkflowParticipantProvider } from "../dist/features/channels/workflow-participant-provider.js"; +import { prepareWorkflowStepStart } from "../dist/features/channels/workflows.js"; +import { selectChannelWorkflowIntent } from "../dist/features/channels/channel-intent-selector.js"; +import { confirmChannelExecutionProposalWorkflow } from "../dist/features/channels/proposal-workflows.js"; +import { prepareChannelCreateBody } from "./conversation-model-test-fixture.mjs"; +import { ensureEmptyGitRepo, ensureGitRepoForChannelPost } from "./git-test-helpers.mjs"; +import { commandOperationCandidate, fileOperationCandidate } from "./support/channel-operation-candidate-fixture.mjs"; +import { messageText, startRealPiChannelFixture, systemText } from "./support/channel-operation-real-pi-fixture.mjs"; +import { + withRestrictedCommandReadiness, + projectionFor, + RecordingRunner, + WorkflowEvidenceRunner, + DelayedCommandEvidenceRunner, + MismatchedExactCommandRunner, + DelayedFileCommandRunner, + escapeRegExp, + ScriptedRunner, + DelayedRunner, + testDaemonConfig, + unusedRunnerRegistry, + assertInvalidModelPlan, + startDaemonForTest, + publishHostOpenAvailability, + createStoreChannelAndMessage, + createChannel, + setWorkspaceDefaultProvider, + createAutoRouteTestChannel, + createStaticChatProvider, + simulateLegacyChannelWithoutModel, + parseSseEvents, + getJson, + assertCoordinatorWorkflowStartReply, + assertIntentDecisionInspectPayloadIsSafe, + assertSingleReplyIntentDecision, + waitForChannelMessageStatus, + waitForCondition, + postJson, + putJson, + patchJson, +} from "./support/channel-workspace-fixture.mjs"; + +test("mentions_only creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentions-only-route-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + simulateLegacyChannelWithoutModel(cwd, channel.id); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个频道自动回复怎么设计?", + }); + + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); + const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(blockedReply.body, "AI teammate is not configured for this channel."); + assert.equal(blockedReply.errorMessage, "No provider preset is selected for this channel discussion."); + assert.equal(runner.runs.length, 0); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].mode, "mentions_only"); + assert.equal(decisions.decisions[0].action, "reply"); + assert.equal(decisions.decisions[0].status, "failed"); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("default_teammate routes root messages through Ling Agent without proposals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-route-")); + const runner = new ScriptedRunner([ + `{"title":"Should not persist","prompt":"Create a task","acceptanceCriteria":["never"],"requiredTools":["read"],"riskSummary":"blocked by proposalMode"}Visible default reply`, + "这个频道当前有多个 AI 队友,具体数量以频道成员列表为准。", + ]); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个实现边界怎么处理?", + }); + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Visible default reply"); + assert.doesNotMatch(finalReply.body, /ling_task_proposal/); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].systemPrompt, /Do not include hidden proposal blocks/); + assert.match(runner.runs[0].task.prompt, /Current teammate: Ling Agent \(id: ling_agent, role: custom\)/); + assert.doesNotMatch(runner.runs[0].task.prompt, /Channel teammates \(configured roster/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].mode, "default_teammate"); + assert.equal(decisions.decisions[0].action, "reply"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.equal(decisions.decisions[0].selectedTeammateId, "ling_agent"); + assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual(proposals.proposals, []); + assert.deepEqual((await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks, []); + const firstReplyRuns = (await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`)).runs; + assert.equal(firstReplyRuns.length, 1); + assert.equal(firstReplyRuns[0].id, finalReply.linkedRunId); + assert.equal(firstReplyRuns[0].status, "succeeded"); + assert.equal(firstReplyRuns[0].taskId, created.message.id); + + const countQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "现在频道有多少个队友", + }); + assert.equal(countQuestion.aiReply.status, "pending"); + const countReplyList = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, countQuestion.aiReply.id, "ready"); + const countReply = countReplyList.messages.find((message) => message.id === countQuestion.aiReply.id); + assert.equal(countReply.body, "这个频道当前有多个 AI 队友,具体数量以频道成员列表为准。"); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[1].task.prompt, /Channel teammates \(configured roster; not online\/presence\):/); + assert.match(runner.runs[1].task.prompt, /Engineer \(id: teammate_builtin_engineer, role: engineer\).*default/); + assert.match(runner.runs[1].task.prompt, /Do not claim online\/presence status/); + + const countDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(countQuestion.message.id)}`); + assert.equal(countDecisions.decisions.length, 1); + assert.equal(countDecisions.decisions[0].mode, "default_teammate"); + assert.equal(countDecisions.decisions[0].action, "reply"); + assert.equal(countDecisions.decisions[0].status, "accepted"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector creates visible multi-replies for discussion-only all-teammate requests", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-visible-multi-reply-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "所有队友都回复我一下", + }); + + assert.equal(created.message.body, "所有队友都回复我一下"); + assert.equal(created.aiReplies.length, 3); + assert.equal(created.aiReply.id, created.aiReplies[0].id); + assert.deepEqual( + created.aiReplies.map((reply) => reply.authorId).sort(), + ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"].sort(), + ); + assert.deepEqual(created.aiReplies.map((reply) => reply.status), ["pending", "pending", "pending"]); + + await Promise.all(created.aiReplies.map((reply) => waitForChannelMessageStatus(daemon.url, cwd, channel.id, reply.id, "ready"))); + assert.equal(runner.runs.length, 3); + assert(runner.runs.every((run) => /Do not include hidden proposal blocks/.test(run.systemPrompt))); + assert(runner.runs.every((run) => /Channel teammates \(configured roster; not online\/presence\):/.test(run.task.prompt))); + assert(runner.runs.every((run) => /Visible multi-reply participants for this batch:/.test(run.task.prompt))); + assert(runner.runs.every((run) => /Architect \(id: teammate_builtin_architect, role: architect\)/.test(run.task.prompt))); + assert(runner.runs.every((run) => /Engineer \(id: teammate_builtin_engineer, role: engineer\)/.test(run.task.prompt))); + assert(runner.runs.every((run) => /QA \(id: teammate_builtin_qa, role: qa\)/.test(run.task.prompt))); + assert(runner.runs.every((run) => /Do not claim online\/presence status/.test(run.task.prompt))); + assert(runner.runs.every((run) => /not-yet-generated peer replies/.test(run.task.prompt))); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(routeDecisions.decisions.length, 0); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "visible_multi_reply"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.deepEqual( + [...decisions.decisions[0].selectedTeammateIds].sort(), + created.aiReplies.map((reply) => reply.authorId).sort(), + ); + assert.deepEqual( + [...decisions.decisions[0].resultItemIds].sort(), + created.aiReplies.map((reply) => reply.id).sort(), + ); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector creates workflow proposals for execution-shaped all-teammate requests", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-workflow-proposal-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "所有队友都回复我一下:Architect 先拆,Engineer 实现,QA 验证", + teammateId: "ling_agent", + requestAiReply: true, + }); + + assert.equal(created.message.body, "所有队友都回复我一下:Architect 先拆,Engineer 实现,QA 验证"); + assert.equal(created.aiReply, undefined); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].sourceMessageId, created.message.id); + assert.equal(proposals.proposals[0].status, "draft"); + assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_engineer"); + assert.deepEqual( + proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), + ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"], + ); + assert.equal(proposals.proposals[0].responseLanguage, "follow_user"); + assert.match(proposals.proposals[0].prompt, /需要协作执行的工作流请求/); + assert.match(proposals.proposals[0].acceptanceCriteria[0], /可审计的提案证据/); + assert.match(proposals.proposals[0].collaborationSteps[0].summary, /实现边界和步骤顺序/); + assert.match(proposals.proposals[0].riskSummary, /工作流提案/); + + const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listedMessages.messages.filter((message) => message.kind === "ai_reply").length, 0); + + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(routeDecisions.decisions.length, 0); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "workflow_proposal"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); + assert.match(decisions.decisions[0].reason, /Workflow proposal created from channel intent/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("multi-participant concrete file workflow preserves operation-derived tools", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-multi-participant-file-tools-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Concrete multi-agent execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const participants = ["teammate_builtin_engineer", "teammate_builtin_qa"]; + for (const teammateId of participants) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer @QA 请协作完成一个最小可验证任务:由 Engineer 创建 r5-channel-proof.txt,内容为 continuity-v2-r5-proof;QA 执行命令验证文件内容。Engineer 与 QA 都必须完成并提供真实 evidence 后才报告成功。", + mentions: [ + { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, + { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, + ], + requestAiReply: true, + }); + assert.ok(created.autoStartedWorkflow); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + const store = stores.get(cwd); + assert.ok(store); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) + .find((candidate) => candidate.workflowId === created.autoStartedWorkflow.workflow.id); + assert.ok(decision); + assert.equal(decision.action, "execute"); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), participants); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requiredOperationIds), [["create_file"], ["verify_target"]]); + const verificationOperation = decision.execution?.operations.find((operation) => operation.id === "verify_target"); + assert.equal(verificationOperation?.commandMatchPolicy?.kind, "exact_command"); + assert.ok(verificationOperation?.commandMatchPolicy?.kind === "exact_command"); + assert.match( + verificationOperation.commandMatchPolicy.command, + new RegExp(`Buffer\\.from\\('${Buffer.from("continuity-v2-r5-proof", "utf8").toString("base64")}\\',\\s*'base64'\\)`), + ); + assert.doesNotMatch(verificationOperation.commandMatchPolicy.command, /QA/u); + + const proposal = store.listChannelExecutionProposals({ + channelId: channel.id, + sourceMessageId: created.message.id, + limit: 20, + })[0]; + assert.ok(proposal); + assert.deepEqual(proposal.requiredTools, ["write", "bash"]); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.toParticipantId), participants); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["verify_target"]]); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.requiredTools), [["write"], ["bash"]]); + const [engineerStep, qaStep] = created.autoStartedWorkflow.workflowSteps; + assert.match(engineerStep.prompt, /create_file: file_create -> r5-channel-proof\.txt/u); + assert.match(qaStep.prompt, /verify_target: run_command -> verify r5-channel-proof\.txt content/u); + assert.ok( + qaStep.prompt.includes(`exact command: ${verificationOperation.commandMatchPolicy.command}`), + "QA step prompt must carry the exact Host-materialized command used by Completion Gate", + ); + assert.match(qaStep.prompt, /do not substitute a semantically equivalent command/u); + assert.deepEqual(runner.runs[0].tools, ["write"]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("review-only participant uses an output packet while execution participant keeps Host evidence", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-review-only-participant-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_engineer"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + } + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Review then execute", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_engineer"]) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer @Engineer 现在开始执行这个多 Agent 工作流:Designer 先评审文件结构和验收标准,Engineer 创建 review-only-proof.js 并运行 node review-only-proof.js。", + mentions: [ + { id: "teammate_builtin_designer", label: "Designer", kind: "ai-teammate" }, + { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, + ], + requestAiReply: true, + }); + + assert.ok(created.autoStartedWorkflow); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.ok(["pending", "ready"].includes(created.aiReply.status)); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); + const [designerStep, engineerStep] = created.autoStartedWorkflow.workflowSteps; + assert.equal(designerStep.toParticipantId, "teammate_builtin_designer"); + assert.equal(designerStep.metadata.delegatedRequirement.expectedOutcome, "synthesis"); + assert.deepEqual(designerStep.metadata.executionOperationIds, []); + assert.equal(engineerStep.toParticipantId, "teammate_builtin_engineer"); + assert.equal(engineerStep.metadata.delegatedRequirement.expectedOutcome, "execution"); + assert.ok(engineerStep.metadata.executionOperationIds.length > 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow Host binding rejects a semantic substitute before Completion Gate", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-exact-command-boundary-")); + const runner = new MismatchedExactCommandRunner(); + const daemon = await startDaemonForTest(runner, undefined, + withRestrictedCommandReadiness({ channels: { intentSelector: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Exact command boundary", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@QA 执行 `node app.js` 验证,并且必须提供真实 evidence。", + mentions: [{ id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }], + requestAiReply: true, + }); + assertCoordinatorWorkflowStartReply(created); + await waitForCondition(() => runner.decisions.length === 1, "workflow exact-command binding rejection"); + assert.equal(runner.decisions[0].approved, false); + assert.match(runner.decisions[0].reason, + /operation_binding_command_out_of_scope: No unique owned operation authorizes this exact command\./u); + await waitForCondition(async () => { + const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(created.autoStartedWorkflow.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + return steps.workflowSteps.every((step) => + step.status === "blocked" && step.metadata?.delegatedOutcome?.kind === "blocked"); + }, "workflow to remain blocked after Host rejection"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector does not assign specialists outside current channel", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-roster-boundary-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Architect 先拆,Engineer 实现,QA 验证", + }); + + assert.equal(created.aiReply, undefined); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); + assert.equal(proposals.proposals[0].proposedByTeammateId, "ling_agent"); + assert.deepEqual(proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), ["ling_agent"]); + assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); + assert.doesNotMatch(proposals.proposals[0].prompt, /Engineer:|QA:|Architect:/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "workflow_proposal"); + assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector uses attached AI council specialists without copying memberships", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-council-specialists-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + const developmentCouncilId = "council_builtin_development"; + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Council-backed Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + } + await putJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/council-policy?cwd=${encodeURIComponent(cwd)}`, { + attachedCouncilIds: [developmentCouncilId], + preferredCouncilId: developmentCouncilId, + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Designer 先 review 交互,QA 验证这个频道设置改动", + }); + + assert.equal(created.aiReply, undefined); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "draft"); + assert.deepEqual( + [...proposals.proposals[0].collaborationSteps.map((step) => step.teammateId)].sort(), + ["teammate_builtin_designer", "teammate_builtin_qa"].sort(), + ); + assert.equal(proposals.proposals[0].collaborationSteps.some((step) => step.teammateId === "ling_agent"), false); + const designerStep = proposals.proposals[0].collaborationSteps.find((step) => step.teammateId === "teammate_builtin_designer"); + assert.ok(designerStep); + assert.ok(designerStep.sources?.some((source) => source.kind === "council" && source.councilDisplayName === "Development Council")); + + const effective = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/effective-council?cwd=${encodeURIComponent(cwd)}`); + const designerSpecialist = effective.effectiveCouncil.specialists.find((specialist) => specialist.teammateId === "teammate_builtin_designer"); + assert.ok(designerSpecialist); + assert.ok(designerSpecialist.sources.some((source) => source.kind === "council" && source.councilId === developmentCouncilId)); + + const confirmed = await postJson(`${daemon.url}/channel-proposals/${encodeURIComponent(proposals.proposals[0].id)}/confirm-workflow?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(confirmed.proposal.status, "accepted"); + assert.deepEqual( + [...confirmed.workflowSteps.map((step) => step.toParticipantId)].sort(), + ["teammate_builtin_designer", "teammate_builtin_qa"].sort(), + ); + const designerWorkflowStep = confirmed.workflowSteps.find((step) => step.toParticipantId === "teammate_builtin_designer"); + assert.ok(designerWorkflowStep); + assert.ok(designerWorkflowStep.metadata?.proposalStepSources?.some((source) => + source.kind === "council" && + source.councilId === developmentCouncilId && + source.councilDisplayName === "Development Council" + )); + const designerChannels = await getJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_designer")}/channels?cwd=${encodeURIComponent(cwd)}`); + const qaChannels = await getJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}/channels?cwd=${encodeURIComponent(cwd)}`); + assert.equal(designerChannels.memberships.some((membership) => membership.channelId === channel.id), false); + assert.equal(qaChannels.memberships.some((membership) => membership.channelId === channel.id), false); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "workflow_proposal"); + assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector asks one clarify question for underspecified work requests", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-clarify-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep"], + })).channel; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "做一下吧", + }); + + assert.equal(created.message.body, "做一下吧"); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.equal(created.aiReply.status, "ready"); + assert.equal(created.aiReply.threadId, created.message.threadId); + assert.equal(created.aiReply.authorId, "ling_agent"); + assert.match(created.aiReply.body, /需要先确认范围/); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(routeDecisions.decisions.length, 0); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "clarify"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].createdBy, "deterministic"); + assert.deepEqual(decisions.decisions[0].selectedTeammateIds, ["ling_agent"]); + assert.deepEqual(decisions.decisions[0].resultItemIds, [created.aiReply.id]); + assert.deepEqual(decisions.decisions[0].clarification.missingFields, ["acceptance_criteria"]); + assert.equal(decisions.decisions[0].clarification.question, created.aiReply.body); + assert.match(decisions.decisions[0].reason, /Clarify question created from channel intent/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel intent selector auto-starts explicit direct workflow proposals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-direct-workflow-")); + const runner = new WorkflowEvidenceRunner(); + const daemon = await startDaemonForTest(runner, {}, { channels: { terminalReports: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "开始跑 Architect -> Engineer -> QA", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 3); + await waitForCondition( + () => runner.runs.filter((run) => !/terminal report synthesizer/i.test(run.systemPrompt ?? "")).length === 3, + "direct workflow proposal runner sequence", + ); + let listedMessages; + await waitForCondition(async () => { + listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + return listedMessages.items.some((item) => + item.kind === "terminal_report" && item.payload?.outcomeStatus === "succeeded"); + }, "direct workflow terminal publication"); + const terminalItem = listedMessages.items.find((item) => + item.kind === "terminal_report" && item.payload?.outcomeStatus === "succeeded"); + assert.ok(terminalItem); + const terminalReport = await getJson(`${daemon.url}/terminal-reports/${encodeURIComponent(terminalItem.payload.reportId)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(terminalReport.report.outcome.status, "succeeded"); + assert.match(terminalReport.report.outcome.summary, /任务已完成/u); + assert.equal(terminalReport.report.participants.length, 3); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.equal(proposals.proposals[0].responseLanguage, "follow_user"); + assert.deepEqual( + proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), + ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"], + ); + assert.match(proposals.proposals[0].riskSummary, /运行时审批策略/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 2); + assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); + assert.equal(decisions.decisions[0].action, "execute"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); + assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); + + assert.equal(created.autoStartedWorkflow.workflow.metadata.responseLanguage, "follow_user"); + assert.match(created.autoStartedWorkflow.workflowSteps[0].prompt, /Match the language of the most recent user message/); + assert.match(created.autoStartedWorkflow.workflowSteps[0].prompt, /API fields, and evidence excerpts in their original language/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit response language overrides workspace default for workflow proposals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-override-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { + responseLanguage: "zh-CN", + }); + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Architect 先拆,Engineer 实现,QA 验证。Please respond in English.", + }); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].responseLanguage, "en-US"); + assert.match(proposals.proposals[0].prompt, /Proposed collaboration/); + assert.match(proposals.proposals[0].acceptanceCriteria[0], /Create auditable proposal evidence/); + assert.match(proposals.proposals[0].riskSummary, /Multi-agent work starts after confirmation/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel response language overrides follow-user inference for workflow proposals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-channel-")); + const daemon = await startDaemonForTest(); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + responseLanguage: "en-US", + allowedTools: ["read", "grep"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Architect 先拆,Engineer 实现,QA 验证", + }); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].responseLanguage, "en-US"); + assert.match(proposals.proposals[0].prompt, /Proposed collaboration/); + assert.match(proposals.proposals[0].riskSummary, /Multi-agent work starts after confirmation/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-step file creation becomes direct workflow with bounded write tools", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Docs execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `在 ${cwd} 下创建 README.md`, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_docs"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].riskSummary, /write/); + assert.match(proposals.proposals[0].prompt, /权限边界/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 2); + assert.equal(decisions.decisions[0].action, "execute"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); + + assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["write"]); + assert.match(runner.runs[0].task.prompt, /Confirmed task-scoped tools:\nwrite/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit command request becomes execute with bounded bash tools", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-command-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Command execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_qa", + autoReplyMode: "default_teammate", + allowedTools: ["read", "bash"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "运行 `pnpm test` 并报告结果。", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); + assert.match(proposals.proposals[0].prompt, /pnpm test/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 2); + assert(decisions.decisions.every((decision) => decision.action === "execute")); + assert(decisions.decisions.every((decision) => decision.execution.operations[0].kind === "run_command")); + assert(decisions.decisions.every((decision) => decision.execution.operations[0].targetKind === "command")); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["bash"]); + assert.match(runner.runs[0].task.prompt, /pnpm test/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit Ling Agent file request becomes direct workflow instead of discussion reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-ling-file-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Mentioned Ling Agent execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `@Ling Agent 在 ${cwd} 下创建 login.html`, + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].prompt, /login\.html/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["write"]); + assert.match(runner.runs[0].task.prompt, /login\.html/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workspace follow-up can create the single file named by the previous Ling Agent reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Follow-up execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我写一个登录页面", + }, + "2026-06-12T09:00:00.000Z", + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "你可以先在工作区新建一个文件:`login.html`,然后把代码粘进去。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "可以直接在工作区帮我做好吗?", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|没有被授权执行工作区写入/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].prompt, /Target: login\.html/); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /login\.html/); + assert.match(runner.runs[0].task.prompt, /Requested objective from recent same-thread\/channel objective/); + assert.match(runner.runs[0].task.prompt, /帮我写一个登录页面/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("thread follow-up can create the single file named by the previous Ling Agent reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Thread follow-up execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我做一个打飞机小游戏", + }, + "2026-06-12T09:00:00.000Z", + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: [ + "如果确认,可以在工作区创建 `index.html`。", + "", + "```html", + "", + "", + "```", + ].join("\n"), + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "你不可以直接帮我写到里面吗?", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created, previousUser.id); + assert.notEqual(created.message.threadId, previousUser.id); + assert.equal(store.getLingThread(created.message.threadId)?.anchorItemId, previousUser.id); + assert.equal(created.aiReply.threadId, created.message.threadId); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].prompt, /Target: index\.html/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["write"]); + assert.match(runner.runs[0].task.prompt, /Target: index\.html/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("thread follow-up opens the recent HTML target with the dedicated Host capability", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-browser-open-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + desktopBridgeToken: "deterministic-open-host-token", + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ defaultWorkspaceCwd: cwd })); + try { + await publishHostOpenAvailability(daemon.url, "deterministic-open-host-token"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Thread browser open execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我做一个打飞机小游戏", + }, + "2026-06-12T09:00:00.000Z", + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "已创建文件:`index.html`。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "可以直接帮我浏览器打开吗?", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created, previousUser.id); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, []); + assert.match(proposals.proposals[0].prompt, /dedicated Ling Host preview capability/); + assert.doesNotMatch(proposals.proposals[0].prompt, /Create index\.html|declared file tool scope/); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, []); + assert.deepEqual(runner.runs[0].hostCapabilityTools?.map((tool) => tool.name), ["host_open_local_target"]); + assert.match(runner.runs[0].task.prompt, /Target: index\.html/); + assert.match(runner.runs[0].task.prompt, /Do not recreate or edit the file/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted thread follow-up opens recent target from typed operation kind", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-open-")); + let previousReply; + const runner = new ScriptedRunner([ + () => JSON.stringify({ + action: "execute", + reason: "User wants to preview the recent local artifact.", + execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "open_target", kind: "open_local_target", + sourceQuote: "可以直接帮我展示一下这个成果吗?", path: "index.html", targetSourceItemId: previousReply.id })] }, + }), + "Opened local target.", + ]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + desktopBridgeToken: "typed-open-host-token", + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ defaultWorkspaceCwd: cwd, channels: { intentSelector: true } })); + try { + await publishHostOpenAvailability(daemon.url, "typed-open-host-token"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model typed local target open", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我做一个打飞机小游戏", + }, + "2026-06-12T09:00:00.000Z", + ); + previousReply = appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "已创建文件:`index.html`。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "可以直接帮我展示一下这个成果吗?", + }); + + assertCoordinatorWorkflowStartReply(created, previousUser.id); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.deepEqual(proposals.proposals[0].requiredTools, []); + assert.match(proposals.proposals[0].prompt, /open_target: open_local_target \(index\.html\)/); + assert.doesNotMatch(proposals.proposals[0].prompt, /file_create|file_edit/); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => + decision.action === "execute" && + decision.createdBy === "model_policy" && + decision.execution?.operations[0]?.kind === "open_local_target" + ), true); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); + assert.deepEqual(runner.runs[1].tools, []); + assert.deepEqual(runner.runs[1].hostCapabilityTools?.map((tool) => tool.name), ["host_open_local_target"]); + assert.match(runner.runs[1].task.prompt, /open_target: open_local_target -> index\.html/); + assert.match(runner.runs[1].task.prompt, /Execute only the operations owned by this step/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted thread follow-up writes the recent local target without phrase-rule matching", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-file-")); + let previousReply; + const content = "\n"; + const daemon = await startRealPiChannelFixture(cwd, (request) => { + if (/channel intent selector/i.test(systemText(request))) return { text: JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write the previously discussed file locally.", + execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", + sourceQuote: "你现在可以帮我写在本地里面了吗?", path: "index.html", targetSourceItemId: previousReply.id, + contentSource: { kind: "literal", text: content, sourceQuote: content, sourceItemId: previousReply.id } })] }, + }) }; + if (!request.messages.some((message) => message.role === "tool")) return { tool: "write", input: { path: "index.html", content } }; + return { text: "Created local file: index.html." }; + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model thread follow-up execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = daemon.store; + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "先记一下以后可能要落地文件", + }, + "2026-06-12T09:00:00.000Z", + ); + previousReply = appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: [ + "如果确认,可以在工作区创建 `index.html`:", + "```html", + "", + "", + "```", + ].join("\n"), + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "你现在可以帮我写在本地里面了吗?", + }); + + assert.ok(created.aiReply, JSON.stringify({ created, decisions: store.listChannelIntentDecisions({ channelId: channel.id, limit: 10 }) })); + assertCoordinatorWorkflowStartReply(created, previousUser.id); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].prompt, /create_file: file_create \(index\.html\)/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(intentDecisions.decisions.some((decision) => + decision.action === "execute" && + decision.createdBy === "model_policy" && + decision.execution?.operations[0]?.kind === "file_create" + ), true); + const createOperation = intentDecisions.decisions.find((decision) => decision.createdBy === "model_policy").execution.operations[0]; + assert.equal(createOperation.binding.contentSource.kind, "literal"); + assert.equal(createOperation.binding.contentSource.text, content); + assert.deepEqual(createOperation.binding.contentSource.sourceRefs.map((ref) => ref.itemId), [previousReply.id]); + const { runs } = await daemon.assertWorkflowCompleted(created, { files: [{ path: "index.html", content }], + actionTypes: ["write"], participantIds: ["ling_agent"] }); + assert.equal(daemon.selectorRequests.length, 1); + assert.match(systemText(daemon.selectorRequests[0]), /channel intent selector/i); + assert.equal(runs.length, 1); + assert.deepEqual(runs[0].requestSnapshot.tools, ["write"]); + assert.match(runs[0].taskSnapshot.prompt, /create_file: file_create -> index\.html/); + assert.match(runs[0].taskSnapshot.prompt, /"kind":"literal"/); + assert.match(runs[0].taskSnapshot.prompt, /gameReady = true/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted create-and-run materializes ordered write and Node operations", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "execute", + reason: "User explicitly requested a file write followed by a Node verification.", + execution: { + schemaVersion: 2, + operations: [ + fileOperationCandidate({ id: "create_file", sourceQuote: "创建 app.js,内容是 console.log('ok')", path: "app.js", + contentSource: { kind: "literal", text: "console.log('ok')", sourceQuote: "console.log('ok')" } }), + commandOperationCandidate({ sourceQuote: "然后运行 node app.js 验证。", command: "node app.js", dependsOn: ["create_file"] }), + ], + }, + }), + "Created and ran app.js.", + ]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model create and run", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "创建 app.js,内容是 console.log('ok'),然后运行 node app.js 验证。", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const store = stores.get(cwd); + assert.ok(store); + const sourceItem = store.getConversationItem(created.message.id); + assert.ok(sourceItem?.turnId); + assert.equal(created.autoStartedWorkflow.workflow.metadata.sourceTurnId, sourceItem.turnId); + const [step] = created.autoStartedWorkflow.workflowSteps; + assert.deepEqual(step.metadata.requiredTools, ["write", "bash"]); + assert.deepEqual(step.metadata.executionOperationIds, ["create_file", "run_command"]); + assert.deepEqual(step.metadata.executionPlan.operations.map((operation) => ({ + id: operation.id, + kind: operation.kind, + dependsOn: operation.dependsOn, + label: operation.targetRef?.label, + commandMatchPolicy: operation.commandMatchPolicy, + })), [ + { id: "create_file", kind: "file_create", dependsOn: [], label: "app.js", commandMatchPolicy: undefined }, + { + id: "run_command", + kind: "run_command", + dependsOn: ["create_file"], + label: "node app.js", + commandMatchPolicy: { kind: "exact_command", command: "node app.js" }, + }, + ]); + assert.deepEqual(runner.runs[1].tools, ["write", "bash"]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted multi-agent file verification binds an actual QA read when no command is supplied", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-multi-agent-file-verify-")); + const daemon = await startRealPiChannelFixture(cwd, (request) => { + if (/channel intent selector/i.test(systemText(request))) return { text: JSON.stringify({ + action: "execute", + reason: "Engineer writes the file and QA verifies its literal content.", + execution: { + schemaVersion: 2, + operations: [ + fileOperationCandidate({ id: "create_file", path: "proof.txt", + sourceQuote: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", + owner: { participantId: "teammate_builtin_engineer", sourceQuote: "Engineer" }, + contentSource: { kind: "literal", text: "model-multi-agent-ok", sourceQuote: "model-multi-agent-ok" } }), + fileOperationCandidate({ id: "verify_target", kind: "file_read", path: "proof.txt", dependsOn: ["create_file"], + sourceQuote: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", + owner: { participantId: "teammate_builtin_qa", sourceQuote: "QA" } }), + ], + }, + }) }; + const isWriter = request.tools.some((tool) => tool.function.name === "write"); + const toolResult = request.messages.find((message) => message.role === "tool"); + if (!toolResult) return isWriter + ? { tool: "write", input: { path: "proof.txt", content: "model-multi-agent-ok" } } + : { tool: "read", input: { path: "proof.txt" } }; + if (!isWriter) assert.equal(messageText(toolResult), "model-multi-agent-ok", "QA must receive the actual file bytes"); + return { text: isWriter ? "Engineer completed proof.txt." : "QA read proof.txt and verified model-multi-agent-ok." }; + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model multi-agent file verification", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + })).channel; + for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", + mentions: [ + { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, + { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, + ], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + const store = daemon.store; + assert.ok(store); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) + .find((candidate) => candidate.action === "execute"); + assert.ok(decision); + const verificationOperation = decision.execution?.operations.find((operation) => operation.id === "verify_target"); + assert.equal(verificationOperation?.kind, "file_read"); + assert.equal(verificationOperation?.commandMatchPolicy, undefined); + assert.equal(verificationOperation?.binding.ownerParticipantId, "teammate_builtin_qa"); + assert.deepEqual(verificationOperation?.binding.inputRefs.map((ref) => ref.path), ["proof.txt"]); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["verify_target"]]); + const { runs, actions } = await daemon.assertWorkflowCompleted(created, { + files: [{ path: "proof.txt", content: "model-multi-agent-ok" }], actionTypes: ["write", "read"], + participantIds: ["teammate_builtin_engineer", "teammate_builtin_qa"], + }); + assert.deepEqual(runs.map((run) => run.requestSnapshot.tools), [["write"], ["read"]]); + assert.deepEqual(actions.map((action) => action.participantId), ["teammate_builtin_engineer", "teammate_builtin_qa"]); + assert.equal(store.getAiTeammate("teammate_builtin_qa").allowedTools.includes("write"), false); + assert.equal(runs[0].requestSnapshot.workflowOperationExecution.plan.fingerprint, runs[1].requestSnapshot.workflowOperationExecution.plan.fingerprint); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted natural-language Node work materializes executable evidence policy", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-node-executable-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "execute", + reason: "User requested a file write followed by a Node invocation.", + execution: { + schemaVersion: 2, + operations: [ + fileOperationCandidate({ id: "create_file", path: "continuity-v2-proof.js", + sourceQuote: "创建 continuity-v2-proof.js,内容是导出 formatProof", + contentSource: { kind: "generated_objective", text: "导出 formatProof", sourceQuote: "导出 formatProof", inputOperationIds: [] } }), + { ...commandOperationCandidate({ sourceQuote: "然后运行 Node 调用 formatProof(\"7319\") 验证。", dependsOn: ["create_file"] }), + commandGoal: { kind: "node_function", module: { path: "continuity-v2-proof.js", sourceQuote: "continuity-v2-proof.js" }, + invocation: 'formatProof("7319")', sourceQuote: 'formatProof("7319")' } }, + ], + }, + }), + "Created and invoked the proof function.", + ]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Natural Node invocation", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "创建 continuity-v2-proof.js,内容是导出 formatProof,然后运行 Node 调用 formatProof(\"7319\") 验证。", + }); + + assertCoordinatorWorkflowStartReply(created); + const store = stores.get(cwd); + assert.ok(store); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 10 }) + .find((candidate) => candidate.action === "execute"); + assert.ok(decision?.execution); + const commandOperation = decision.execution.operations.find((operation) => operation.id === "run_command"); + assert.equal(commandOperation?.commandMatchPolicy?.kind, "exact_command"); + assert.equal(commandOperation?.binding?.commandGoal?.modulePath, "continuity-v2-proof.js"); + assert.equal(commandOperation?.binding?.commandGoal?.invocation, 'formatProof("7319")'); + assert.deepEqual(commandOperation?.binding?.effectScope.readPaths, ["continuity-v2-proof.js"]); + assert.deepEqual(commandOperation?.dependsOn, ["create_file"]); + assert.equal(commandOperation?.targetRef?.label, 'continuity-v2-proof.js → formatProof("7319")'); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("partial existing proposal lineage becomes one durable blocker without participant reselection", async () => { + for (const scenario of [ + { + name: "single-step", + body: "创建 orphan-proof.js,内容是 console.log('proof')。", + requiredTools: ["write"], + }, + { + name: "multi-participant", + body: "请让 Engineer 和 QA 一起实现并测试这个功能。", + requiredTools: ["read"], + }, + ]) { + const cwd = await mkdtemp(resolve(tmpdir(), `ling-stack-channel-partial-proposal-${scenario.name}-`)); + const store = createSqliteRunStore(cwd); + try { + const { channel, message } = createStoreChannelAndMessage(store, cwd, scenario.body); + const proposal = store.createChannelExecutionProposal({ + channelId: channel.id, + sourceMessageId: message.id, + proposedByTeammateId: "ling_agent", + title: `Partial ${scenario.name} proposal`, + prompt: scenario.body, + assigneeTeammateId: "ling_agent", + requiredTools: scenario.requiredTools, + riskSummary: "Simulated crash after proposal persistence and before decision persistence.", + status: "draft", + }, "2026-08-16T11:00:00.000Z"); + + const first = await selectChannelWorkflowIntent({ + store, + channel, + sourceMessage: message, + secretResolver: createCompositeSecretResolver({ env: {} }), + }); + + assert.equal(first.action, "blocked"); + assert.equal(first.proposalId, proposal.id); + assert.equal(first.errorCode, "existing_proposal_participant_lineage_invalid"); + assert.equal(first.aiReply.status, "blocked"); + assert.equal(first.aiReply.errorMessage, `existing_proposal_participant_lineage_invalid:${proposal.id}`); + assert.equal(store.listChannelIntentDecisions({ sourceItemId: message.id, limit: 20 }).length, 0); + assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); + assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); + + const repeated = await selectChannelWorkflowIntent({ + store, + channel, + sourceMessage: message, + secretResolver: createCompositeSecretResolver({ env: {} }), + }); + assert.equal(repeated.action, "blocked"); + assert.equal(repeated.aiReply.id, first.aiReply.id); + assert.equal( + store.listConversationItemsForTurn({ turnId: store.getConversationItem(message.id).turnId }) + .filter((item) => item.kind === "assistant_message" && item.payload.deliveryStatus === "blocked").length, + 1, + ); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("partial existing proposal lineage is blocked consistently over JSON and SSE dispatch", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-partial-proposal-transport-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + const appendConversationUserTurn = store.appendConversationUserTurn.bind(store); + store.appendConversationUserTurn = (input, now) => { + const accepted = appendConversationUserTurn(input, now); + const existing = store.listChannelExecutionProposals({ + channelId: input.channelId, + sourceMessageId: accepted.item.id, + limit: 1, + })[0]; + if (!existing && input.userInstruction.includes("orphan-transport")) { + store.createChannelExecutionProposal({ + channelId: input.channelId, + sourceMessageId: accepted.item.id, + proposedByTeammateId: "ling_agent", + title: "Injected partial proposal lineage", + prompt: input.userInstruction, + assigneeTeammateId: "ling_agent", + requiredTools: ["write"], + riskSummary: "Injected between accepted-turn commit and selector dispatch.", + status: "draft", + }, now); + } + return accepted; + }; + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Partial proposal transport", + status: "active", + boundPath: cwd, + allowedTools: ["read", "write"], + })).channel; + const requestBody = { + body: "创建 orphan-transport-json.js,内容是 console.log('proof')。", + requestAiReply: true, + idempotencyKey: "partial-proposal-json", + }; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, requestBody); + assert.equal(created.aiReply.status, "blocked"); + assert.match(created.aiReply.errorMessage, /^existing_proposal_participant_lineage_invalid:proposal_/); + const replay = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, requestBody); + assert.equal(replay.message.id, created.message.id); + assert.equal(replay.aiReply.id, created.aiReply.id); + + const sseResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: "创建 orphan-transport-sse.js,内容是 console.log('proof')。", + requestAiReply: true, + idempotencyKey: "partial-proposal-sse", + }), + }); + assert.equal(sseResponse.status, 200); + const events = parseSseEvents(await sseResponse.text()); + assert.equal(events[0].event, "ready"); + assert.equal(events.at(-1).event, "blocked"); + assert.equal(events.some((event) => event.event === "error"), false); + assert.match(events.at(-1).data.aiReply.errorMessage, /^existing_proposal_participant_lineage_invalid:proposal_/); + + const store = stores.get(cwd); + assert.ok(store); + for (const sourceMessageId of [created.message.id, events[0].data.message.id]) { + const sourceItem = store.getConversationItem(sourceMessageId); + assert.ok(sourceItem?.turnId); + assert.equal(store.getLingTurn(sourceItem.turnId).status, "blocked"); + const dispatch = store.findWorkUnitByIdempotency({ + kind: "channel_turn_dispatch", + ownerType: "ling_turn", + ownerId: sourceItem.turnId, + idempotencyKey: `channel-turn-dispatch:${sourceItem.turnId}`, + }); + assert.equal(dispatch.status, "succeeded"); + assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) + .filter((item) => item.kind === "assistant_message" && item.payload.deliveryStatus === "blocked").length, 1); + assert.equal(store.listChannelIntentDecisions({ sourceItemId: sourceMessageId, limit: 20 }).length, 0); + } + assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); + assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); + assert.equal(runner.runs.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit custom executable participant is preserved ahead of same-role fallback", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-preservation-")); + const command = `node -e "process.stdout.write('proof:7319')"`; + const systemPrompt = "PROMISEBOT_BASE_PROFILE_MARKER: return the configured participant behavior."; + const appendSystemPrompt = "PROMISEBOT_APPEND_PROFILE_MARKER: preserve the delegated participant profile."; + const approvalProfile = { + mode: "standard", + risk: "high", + requiredForTools: ["bash"], + requiredForActions: ["git_push"], + }; + const runner = new DelayedCommandEvidenceRunner(command); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ channels: { intentSelector: true, terminalReports: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const builtInEngineerId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(builtInEngineerId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + allowedTools: ["read", "bash"], + }); + const customEngineer = (await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { + roleKey: "engineer", + displayName: "PromiseBot", + description: "Runs the explicitly assigned verification command.", + providerPresetId: provider.id, + systemPrompt, + appendSystemPrompt: [appendSystemPrompt], + allowedTools: ["read", "bash"], + approvalProfile, + status: "active", + })).teammate; + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Explicit participant preservation", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: builtInEngineerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + for (const teammateId of [builtInEngineerId, customEngineer.id]) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `;@PromiseBot 运行 \`${command}\` 并验证输出严格等于 proof:7319。`, + mentions: [ + { id: customEngineer.id, label: customEngineer.displayName, kind: "ai-teammate" }, + { id: customEngineer.id, label: customEngineer.displayName, kind: "ai-teammate" }, + ], + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + const store = stores.get(cwd); + assert.ok(store); + const sourceItem = store.getConversationItem(created.message.id); + assert.deepEqual(sourceItem.payload.mentions.map((mention) => mention.id), [customEngineer.id]); + const workflowId = created.autoStartedWorkflow.workflow.id; + const decision = store.listChannelIntentDecisions({ sourceItemId: sourceItem.id, limit: 20 }) + .find((candidate) => candidate.workflowId === workflowId); + assert.ok(decision); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), [customEngineer.id]); + assert.deepEqual(decision.selectedTeammateIds, [customEngineer.id]); + const proposal = store.listChannelExecutionProposals({ + channelId: channel.id, + sourceMessageId: sourceItem.id, + limit: 20, + })[0]; + assert.ok(proposal); + assert.equal(proposal.assigneeTeammateId, customEngineer.id); + assert.deepEqual(proposal.collaborationSteps.map((step) => step.teammateId), [customEngineer.id]); + assert.deepEqual( + created.autoStartedWorkflow.workflow.metadata.requiredParticipantManifest.participants + .map((participant) => participant.participantId), + [customEngineer.id], + ); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const [step] = created.autoStartedWorkflow.workflowSteps; + assert.equal(step.toParticipantId, customEngineer.id); + assert.equal(step.participantAuthority.kind, "channel_selection"); + assert.equal(step.participantAuthority.origin, "channel_intent_proposal"); + assert.equal(step.participantAuthority.sourceId, proposal.id); + assert.equal(step.participantAuthority.participantId, customEngineer.id); + assert.deepEqual(step.metadata.providerBinding, { + schemaVersion: 1, + participantId: customEngineer.id, + bindingMode: "fixed", + source: "teammate_fixed", + providerPresetId: provider.id, + }); + assert.equal(step.metadata.delegatedRequirement.participantId, customEngineer.id); + await waitForCondition(() => runner.executionRuns.length === 1, "explicit custom participant execution run to start"); + const executionRun = runner.executionRuns[0]; + assert.equal(executionRun.systemPrompt, systemPrompt); + assert.equal(executionRun.appendSystemPrompt.includes(appendSystemPrompt), true); + assert.equal(executionRun.appendSystemPrompt.filter((prompt) => prompt.startsWith("Ling conversation response style v1:")).length, 1); + assert.deepEqual(executionRun.tools, ["bash"]); + const run = store.getRun(store.getChannelWorkflowStep(step.id).runId); + assert.equal(run.teammateId, customEngineer.id); + assert.equal(run.requestSnapshot.systemPrompt, systemPrompt); + assert.equal(run.requestSnapshot.appendSystemPrompt.includes(appendSystemPrompt), true); + assert.deepEqual(run.requestSnapshot.approvalProfile, approvalProfile); + assert.deepEqual(run.requestSnapshot.tools, ["bash"]); + assert.deepEqual(run.requestSnapshot.providerBinding, step.metadata.providerBinding); + assert.equal(JSON.stringify({ decision, proposal, workflow: created.autoStartedWorkflow.workflow, step, run }).includes(builtInEngineerId), false); + + const assigneeOverride = resolveWorkflowParticipantRunProfile({ + store, + channel: store.getChannel(channel.id), + step: store.getChannelWorkflowStep(step.id), + requestedAssigneeTeammateId: builtInEngineerId, + requiredTools: ["bash"], + }); + assert.equal(assigneeOverride.kind, "blocked"); + assert.equal(assigneeOverride.code, "workflow_participant_override_forbidden"); + assert.deepEqual(assigneeOverride.missingRequirements, ["assigned_participant"]); + + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(customEngineer.id)}?cwd=${encodeURIComponent(cwd)}`, { + allowedTools: ["read"], + }); + const toolShrink = resolveWorkflowParticipantRunProfile({ + store, + channel: store.getChannel(channel.id), + step: store.getChannelWorkflowStep(step.id), + requestedAssigneeTeammateId: customEngineer.id, + requiredTools: ["bash"], + }); + assert.equal(toolShrink.kind, "blocked"); + assert.equal(toolShrink.code, "workflow_participant_required_tools_unavailable"); + assert.deepEqual(toolShrink.missingRequirements, ["tool:bash"]); + assert.equal(runner.executionRuns.length, 1); + + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(customEngineer.id)}?cwd=${encodeURIComponent(cwd)}`, { + status: "paused", + }); + const recovered = await selectChannelWorkflowIntent({ + store, + channel: store.getChannel(channel.id), + sourceMessage: projectConversationItemToChannelMessage(sourceItem), + secretResolver: createCompositeSecretResolver({ env: {} }), + }); + assert.equal(recovered.action, "workflow_proposal"); + assert.equal(recovered.proposal.id, proposal.id); + assert.equal(recovered.proposal.assigneeTeammateId, customEngineer.id); + assert.equal(recovered.intentDecisionId, decision.id); + } finally { + runner.releaseExecution(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow-definition authority does not require a direct Channel membership row", async (t) => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-definition-authority-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + const store = createSqliteRunStore(cwd); + try { + const now = "2026-08-16T02:00:00.000Z"; + const channel = store.createChannel({ + name: "Workflow authority", + boundPath: cwd, + allowedTools: ["read"], + }, now); + store.replaceTeammateChannelMemberships("teammate_builtin_engineer", [], now); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Graph-owned participant", + objective: "Run an assigned graph participant.", + status: "ready", + createdBy: "local-user", + }, now); + const step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + title: "Engineer step", + prompt: "Read the workspace.", + toParticipantId: "teammate_builtin_engineer", + toParticipantLabel: "Engineer", + participantAuthority: createWorkflowParticipantAuthorityV1({ + kind: "workflow_definition", + origin: "workflow_graph", + participantId: "teammate_builtin_engineer", + sourceId: "graph-authority-test", + }), + metadata: { requiredTools: ["read"] }, + }, now); + const resolution = resolveWorkflowParticipantRunProfile({ + store, + channel, + step, + requiredTools: ["read"], + }); + assert.equal(resolution.kind, "ready"); + assert.equal(resolution.teammateId, "teammate_builtin_engineer"); + } finally { + store.close(); + } +}); + +test("proposal provider preflight blocks missing workspace default and freezes configured participant bindings", async (t) => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-provider-preflight-")); + t.after(() => rm(cwd, { recursive: true, force: true })); + const store = createSqliteRunStore(cwd); + try { + const now = "2026-08-17T02:00:00.000Z"; + const channel = store.createChannel({ + name: "Provider preflight", + boundPath: cwd, + defaultProviderPresetId: "provider_channel_ling_agent", + allowedTools: ["read"], + }, now); + for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { + store.replaceTeammateChannelMemberships(teammateId, [{ channelId: channel.id }], now); + } + const thread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Engineer and QA", + objective: "Engineer implements and QA verifies.", + }, now).thread; + const accepted = store.appendConversationUserTurn({ + channelId: channel.id, + threadId: thread.id, + title: "Engineer and QA", + userInstruction: "Engineer and QA", + payload: { body: "Engineer and QA", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, + idempotencyKey: "workflow-provider-preflight", + }, now); + const manifest = createRequiredParticipantManifestV1([ + { + participantId: "teammate_builtin_engineer", + role: "engineer", + requirement: "required", + expectedOutcome: "execution", + requiredOperationIds: [], + requestedBy: "structured_mention", + }, + { + participantId: "teammate_builtin_qa", + role: "qa", + requirement: "required", + expectedOutcome: "verification", + requiredOperationIds: [], + requestedBy: "structured_mention", + }, + ]); + const proposal = store.createChannelExecutionProposal({ + channelId: channel.id, + sourceMessageId: accepted.item.id, + proposedByTeammateId: "ling_agent", + title: "Engineer and QA", + prompt: "Engineer and QA", + assigneeTeammateId: "teammate_builtin_engineer", + collaborationSteps: [ + { teammateId: "teammate_builtin_engineer", action: "coordinated", summary: "Implement." }, + { teammateId: "teammate_builtin_qa", action: "reviewed", summary: "Verify." }, + ], + status: "draft", + }, now); + store.createChannelIntentDecision({ + channelId: channel.id, + sourceItemId: accepted.item.id, + contextProjection: projectionFor( + accepted.item, + store.getEffectiveTurnContextAssignment(accepted.turnId), + ), + action: "workflow_proposal", + selectedTeammateIds: ["teammate_builtin_engineer", "teammate_builtin_qa"], + participantManifest: manifest, + proposalId: proposal.id, + reason: "Engineer implements and QA verifies.", + status: "accepted", + createdBy: "deterministic", + }, now); + + let validationCalls = 0; + assert.throws(() => confirmChannelExecutionProposalWorkflow({ + store, + cwd, + proposalId: proposal.id, + validateRunRequest: () => { validationCalls += 1; }, + now, + }), /no workspace default is configured/); + assert.equal(validationCalls, 0); + const blocked = store.getChannelExecutionProposal(proposal.id); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.blocker.code, "workflow_participant_provider_unavailable"); + assert.equal(blocked.blocker.reasonCode, "workspace_default_provider_missing"); + assert.equal(blocked.blocker.owner, "workspace"); + assert.equal(blocked.blocker.repairTarget, "model_workbench"); + assert.equal(blocked.blocker.providerPresetId, undefined); + assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); + + store.updateWorkspaceSettings({ defaultProviderPresetId: "provider_workspace" }, now); + const validatedProviderIds = []; + const confirmed = confirmChannelExecutionProposalWorkflow({ + store, + cwd, + proposalId: proposal.id, + validateRunRequest: (request) => { validatedProviderIds.push(request.providerPresetId); }, + now: "2026-08-17T02:01:00.000Z", + }); + assert.deepEqual(validatedProviderIds, ["provider_workspace", "provider_workspace"]); + assert.equal(confirmed.proposal.status, "accepted"); + assert.equal(confirmed.proposal.blocker, undefined); + assert.equal(confirmed.proposal.errorMessage, undefined); + assert.deepEqual(confirmed.workflowSteps.map((step) => step.metadata.providerBinding), [ + { + schemaVersion: 1, + participantId: "teammate_builtin_engineer", + bindingMode: "workspace_default", + source: "workspace_default", + providerPresetId: "provider_workspace", + }, + { + schemaVersion: 1, + participantId: "teammate_builtin_qa", + bindingMode: "workspace_default", + source: "workspace_default", + providerPresetId: "provider_workspace", + }, + ]); + assert.deepEqual(confirmed.workflowSteps.map((step) => step.metadata.providerPresetId), [ + "provider_workspace", + "provider_workspace", + ]); + + const engineerStep = confirmed.workflowSteps[0]; + const engineer = store.getAiTeammate(engineerStep.toParticipantId); + assert.ok(engineer); + store.updateWorkspaceSettings({ defaultProviderPresetId: "provider_workspace_repaired" }, "2026-08-17T02:02:00.000Z"); + const resumedProviderIds = []; + const resumed = preflightWorkflowParticipantProvider({ + store, + channel, + teammate: engineer, + cwd, + validateRunRequest: (request) => { resumedProviderIds.push(request.providerPresetId); }, + frozenBinding: engineerStep.metadata.providerBinding, + }); + assert.equal(resumed.kind, "ready"); + assert.deepEqual(resumedProviderIds, ["provider_workspace"]); + assert.equal(resumed.binding.providerPresetId, "provider_workspace"); + + const unavailable = preflightWorkflowParticipantProvider({ + store, + channel, + teammate: engineer, + cwd, + validateRunRequest: () => { throw new Error("Provider disabled after proposal confirmation."); }, + frozenBinding: engineerStep.metadata.providerBinding, + }); + assert.equal(unavailable.kind, "blocked"); + assert.equal(unavailable.blocker.reasonCode, "provider_unavailable"); + assert.equal(unavailable.blocker.providerPresetId, "provider_workspace"); + assert.equal(unavailable.blocker.owner, "workspace"); + + const override = await prepareWorkflowStepStart( + store, + engineerStep.id, + { mode: "create_and_run_task", providerPresetId: "provider_workspace_repaired" }, + cwd, + () => undefined, + ); + assert.equal(override.kind, "blocked"); + const blockedStep = store.getChannelWorkflowStep(engineerStep.id); + assert.equal(blockedStep.metadata.delegatedOutcome.kind, "blocked"); + assert.equal(blockedStep.metadata.delegatedOutcome.code, "workflow_participant_provider_override_forbidden"); + assert.equal(blockedStep.taskId, undefined); + assert.equal(blockedStep.runId, undefined); + + const qaStep = confirmed.workflowSteps[1]; + const { + providerBinding: _providerBinding, + dependsOnStepIds: _dependsOnStepIds, + ...qaMetadataWithoutBinding + } = qaStep.metadata; + store.database.prepare("UPDATE channel_workflow_steps SET metadata_json = ? WHERE id = ?") + .run(JSON.stringify(qaMetadataWithoutBinding), qaStep.id); + const missingBinding = await prepareWorkflowStepStart( + store, + qaStep.id, + { mode: "create_and_run_task" }, + cwd, + () => undefined, + ); + assert.equal(missingBinding.kind, "blocked"); + assert.match(missingBinding.blocker, /no valid frozen provider binding/); + const missingBindingStep = store.getChannelWorkflowStep(qaStep.id); + assert.equal(missingBindingStep.taskId, undefined); + assert.equal(missingBindingStep.runId, undefined); + } finally { + store.close(); + } +}); + +test("explicit executable participant missing a required tool blocks without substitution", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-tool-block-")); + const command = `node -e "process.stdout.write('proof:7319')"`; + const runner = new DelayedCommandEvidenceRunner(command); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const builtInEngineerId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(builtInEngineerId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + allowedTools: ["read", "bash"], + }); + const namedEngineer = (await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { + roleKey: "engineer", + displayName: "ReadOnlyBot", + description: "Explicitly assigned but intentionally lacks bash.", + providerPresetId: provider.id, + allowedTools: ["read"], + approvalProfile: { mode: "none" }, + status: "active", + })).teammate; + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Explicit participant tool blocker", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: builtInEngineerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + for (const teammateId of [builtInEngineerId, namedEngineer.id]) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `;@ReadOnlyBot 运行 \`${command}\` 并验证输出。`, + mentions: [{ id: namedEngineer.id, label: namedEngineer.displayName, kind: "ai-teammate" }], + requestAiReply: true, + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.executionRuns.length, 0); + const store = stores.get(cwd); + assert.ok(store); + const proposal = store.listChannelExecutionProposals({ + channelId: channel.id, + sourceMessageId: created.message.id, + limit: 20, + })[0]; + assert.ok(proposal); + assert.equal(proposal.status, "blocked"); + assert.equal(proposal.assigneeTeammateId, namedEngineer.id); + assert.match(proposal.errorMessage, /缺少所需工具[::]bash|missing required tools?:? bash/iu); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) + .find((candidate) => candidate.proposalId === proposal.id); + assert.ok(decision); + assert.deepEqual(decision.selectedTeammateIds, [namedEngineer.id]); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), [namedEngineer.id]); + assert.equal(decision.errorCode, "participant_required_tools_unavailable"); + assert.equal(JSON.stringify({ decision, proposal }).includes(builtInEngineerId), false); + } finally { + runner.releaseExecution(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("distinct explicit same-role participants remain separate workflow requirements", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-same-role-participants-")); + const runner = new DelayedFileCommandRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const participants = []; + for (const displayName of ["BuilderOne", "BuilderTwo"]) { + participants.push((await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { + roleKey: "engineer", + displayName, + description: `${displayName} owns one explicitly assigned operation.`, + providerPresetId: provider.id, + allowedTools: ["read", "write", "bash"], + approvalProfile: { mode: "none" }, + status: "active", + })).teammate); + } + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Explicit same-role participants", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + })).channel; + for (const participant of participants) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(participant.id)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const requestBody = "@BuilderOne @BuilderTwo 创建 participant-proof.js,内容是 console.log('proof'),然后运行 node participant-proof.js 验证。"; + runner.selectorExecution = { schemaVersion: 2, operations: [ + fileOperationCandidate({ id: "create_file", path: "participant-proof.js", sourceQuote: requestBody, + owner: { participantId: participants[0].id, sourceQuote: "BuilderOne" }, + contentSource: { kind: "literal", text: "console.log('proof')", sourceQuote: "console.log('proof')" } }), + commandOperationCandidate({ command: "node participant-proof.js", sourceQuote: requestBody, dependsOn: ["create_file"], + owner: { participantId: participants[1].id, sourceQuote: "BuilderTwo" } }), + ] }; + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: requestBody, + mentions: participants.map((participant) => ({ + id: participant.id, + label: participant.displayName, + kind: "ai-teammate", + })), + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + const participantIds = participants.map((participant) => participant.id); + const store = stores.get(cwd); + assert.ok(store); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) + .find((candidate) => candidate.workflowId === created.autoStartedWorkflow.workflow.id); + assert.ok(decision); + assert.deepEqual(decision.selectedTeammateIds, participantIds); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), participantIds); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requestedBy), ["structured_mention", "structured_mention"]); + assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requiredOperationIds), [["create_file"], ["run_command"]]); + const proposal = store.listChannelExecutionProposals({ + channelId: channel.id, + sourceMessageId: created.message.id, + limit: 20, + })[0]; + assert.deepEqual(proposal.collaborationSteps.map((step) => step.teammateId), participantIds); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.toParticipantId), participantIds); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["run_command"]]); + await waitForCondition(() => runner.executionRuns.length === 1, "first explicit same-role participant run to start"); + } finally { + runner.releaseAll(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("multi-participant executable request blocks when one explicit participant owns no required operation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-operation-assignment-block-")); + const command = `node -e "process.stdout.write('proof:7319')"`; + const runner = new DelayedCommandEvidenceRunner(command); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const participants = []; + for (const displayName of ["RunnerOne", "RunnerTwo"]) { + participants.push((await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { + roleKey: "engineer", + displayName, + description: `${displayName} is explicitly requested for one command.`, + providerPresetId: provider.id, + allowedTools: ["read", "bash"], + approvalProfile: { mode: "none" }, + status: "active", + })).teammate); + } + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Explicit operation assignment blocker", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + for (const participant of participants) { + await putJson(`${daemon.url}/teammates/${encodeURIComponent(participant.id)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + runner.selectorExecution = { schemaVersion: 2, operations: [commandOperationCandidate({ command, + sourceQuote: `@RunnerOne @RunnerTwo 运行 \`${command}\` 并验证输出。`, + owner: { participantId: participants[0].id, sourceQuote: "RunnerOne" } })] }; + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `@RunnerOne @RunnerTwo 运行 \`${command}\` 并验证输出。`, + mentions: participants.map((participant) => ({ + id: participant.id, + label: participant.displayName, + kind: "ai-teammate", + })), + requestAiReply: true, + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.selectorRuns.length, 1, "the operation graph must reach the model intent selector"); + assert.equal(runner.executionRuns.length, 0); + const store = stores.get(cwd); + assert.ok(store); + const proposals = store.listChannelExecutionProposals({ + channelId: channel.id, + sourceMessageId: created.message.id, + limit: 20, + }); + assert.deepEqual(proposals, []); + const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) + .find((candidate) => candidate.action === "clarify"); + assert.ok(decision); + assert.deepEqual(decision.clarification.missingFields, ["participant.operations"]); + assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); + } finally { + runner.releaseExecution(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-participant execute acknowledgement stays non-terminal until callback publication settles the source turn", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-participant-convergence-")); + const command = `node -e "process.stdout.write('proof:7319')"`; + const runner = new DelayedCommandEvidenceRunner(command); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, withRestrictedCommandReadiness({ channels: { intentSelector: true, terminalReports: true } })); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const engineerId = "teammate_builtin_engineer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(engineerId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Single participant convergence", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: engineerId, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + await putJson(`${daemon.url}/teammates/${encodeURIComponent(engineerId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `;@Engineer 运行 \`${command}\` 并验证输出严格等于 proof:7319。`, + requestAiReply: true, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const store = stores.get(cwd); + assert.ok(store); + const sourceItem = store.getConversationItem(created.message.id); + assert.ok(sourceItem?.turnId); + const workflowId = created.autoStartedWorkflow.workflow.id; + const [step] = created.autoStartedWorkflow.workflowSteps; + const acceptedDecision = store.listChannelIntentDecisions({ sourceItemId: sourceItem.id, limit: 20 }) + .find((decision) => decision.workflowId === workflowId); + assert.ok(acceptedDecision); + assert.deepEqual(acceptedDecision.participantManifest.participants.map((participant) => ({ + participantId: participant.participantId, + requirement: participant.requirement, + expectedOutcome: participant.expectedOutcome, + })), [{ + participantId: engineerId, + requirement: "required", + expectedOutcome: "execution", + }]); + await waitForCondition(() => runner.executionRuns.length === 1, "single participant execution run to start"); + const runningStep = store.getChannelWorkflowStep(step.id); + const runningRun = store.getRun(runningStep.runId); + assert.equal(runningRun.status, "running"); + assert.equal(runningRun.workflowStepId, step.id); + assert.equal(store.getLingTurn(sourceItem.turnId).status, "running"); + assert.equal(store.getConversationItem(created.aiReply.id).payload.deliveryStatus, "ready"); + assert.deepEqual(store.listTerminalReports({ sourceKind: "workflow", sourceId: workflowId, limit: 20 }), []); + + runner.releaseExecution(); + await waitForCondition( + () => store.getLingTurn(sourceItem.turnId)?.status === "succeeded", + "single participant callback publication to settle the source turn", + ); + + const finalRun = store.getRun(runningRun.id); + const finalStep = store.getChannelWorkflowStep(step.id); + const finalWorkflow = store.getChannelWorkflow(workflowId); + const reports = store.listTerminalReports({ sourceKind: "workflow", sourceId: workflowId, limit: 20 }); + assert.equal(finalRun.status, "succeeded"); + assert.equal(finalStep.status, "succeeded"); + assert.equal(finalStep.metadata.delegatedOutcome.kind, "completed"); + assert.equal(finalWorkflow.status, "succeeded"); + assert.equal(finalWorkflow.metadata.completionGate.status, "verified"); + assert.equal(reports.length, 1); + assert.ok(reports[0].resultItemId); + assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) + .filter((item) => item.id === reports[0].resultItemId).length, 1); + assert.equal(store.listStateTransitionEvents({ entityType: "turn", entityId: sourceItem.turnId }) + .filter((event) => event.toStatus === "succeeded").length, 1); + assert.equal(store.listControlPlaneEvents({ + workspaceId: sourceItem.workspaceId, + domains: ["work_units"], + entityType: "terminal_report", + entityId: reports[0].id, + action: "terminal_report.published", + limit: 20, + }).length, 1); + } finally { + runner.releaseExecution(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted create-and-run blocks an execute candidate missing its required command", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-command-clarify-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "execute", + reason: "User requested a file write and a follow-up run, but supplied no exact command.", + execution: { + schemaVersion: 2, + operations: [ + fileOperationCandidate({ id: "create_file", sourceQuote: "创建 app.js,内容是 console.log('ok')", path: "app.js", + contentSource: { kind: "literal", text: "console.log('ok')", sourceQuote: "console.log('ok')" } }), + commandOperationCandidate({ sourceQuote: "然后运行验证。", dependsOn: ["create_file"] }), + ], + }, + }), + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model create and run command clarify", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "write", "bash"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "创建 app.js,内容是 console.log('ok'),然后运行验证。", + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "command"); + assert.equal(runner.runs.length, 2, "one bounded repair before a failed planning diagnostic"); + await assert.rejects(access(resolve(cwd, "app.js"))); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted thread follow-up blocks an execute candidate instead of creating empty recent target", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-empty-file-")); + let previousReply; + const runner = new ScriptedRunner([ + () => JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write the previously discussed file locally.", + execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", + sourceQuote: "你现在可以帮我写在本地里面了吗?", path: "index.html", targetSourceItemId: previousReply.id })] }, + }), + ]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model thread follow-up empty target", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "先记一下以后可能要落地文件", + }, + "2026-06-12T09:00:00.000Z", + ); + previousReply = appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "如果确认,可以在工作区创建 `index.html`。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "你现在可以帮我写在本地里面了吗?", + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); + assert.equal(created.aiReply.authorId, "ling_agent"); + assert.match(created.aiReply.body, /未启动/u); + assert.doesNotMatch(created.aiReply.body, /contentSource/u); + await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "contentSource"); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + assert.equal(runner.runs.length, 2, "one bounded source-binding repair before a failed planning diagnostic"); + await assert.rejects(access(resolve(cwd, "index.html"))); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted local write request blocks an execute candidate when no target validates", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-followup-missing-target-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "execute", + reason: "User is asking Ling to write something locally.", + execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", + sourceQuote: "你现在可以帮我写在本地里面了吗?" })] }, + }), + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model local write clarify", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "你现在可以帮我写在本地里面了吗?", + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); + assert.equal(created.aiReply.authorId, "ling_agent"); + assert.match(created.aiReply.body, /未启动/u); + assert.doesNotMatch(created.aiReply.body, /\btarget\b/iu); + assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); + assert.equal(runner.runs.length, 2, "one bounded source-binding repair before a failed planning diagnostic"); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "target"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workspace follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-ambiguous-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ambiguous follow-up execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我做一个登录页并补 README", + }, + "2026-06-12T09:00:00.000Z", + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "可以新建 `login.html`,也可以把说明补到 `README.md`。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "可以直接在工作区帮我做好吗?", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.status, "ready"); + assert.match(created.aiReply.body, /多个可能的文件目标/); + assert.match(created.aiReply.body, /login\.html/); + assert.match(created.aiReply.body, /README\.md/); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "clarify"); + assert.match(decisions.decisions[0].reason, /multiple file targets/i); + assert.equal(runner.runs.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("thread follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-ambiguous-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ambiguous thread follow-up execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "帮我做一个登录页面和说明文档", + }, + "2026-06-12T09:00:00.000Z", + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: previousUser.threadId, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "可以新建 `login.html`,也可以把说明补到 `README.md`。", + status: "ready", + }, + "2026-06-12T09:01:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "你直接帮我写到里面", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.status, "ready"); + assert.equal(created.aiReply.threadId, created.message.threadId); + assert.match(created.aiReply.body, /多个可能的文件目标/); + assert.match(created.aiReply.body, /login\.html/); + assert.match(created.aiReply.body, /README\.md/); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "clarify"); + assert.match(decisions.decisions[0].reason, /multiple file targets/i); + assert.equal(runner.runs.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("text attachment preview can feed a direct README write workflow", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-preview-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Attachment README execution", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "readme-copy.txt", + contentType: "text/plain", + contentBase64: Buffer.from("README body from attachment", "utf8").toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把附件内容写进 README.md", + attachmentIds: [attachment.id], + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.equal(runner.runs.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.match(proposals.proposals[0].prompt, /Attachment: readme-copy\.txt/); + assert.match(proposals.proposals[0].prompt, /README body from attachment/); + assert.match(runner.runs[0].task.prompt, /README body from attachment/); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("text attachment materialization cache hit avoids rereading attachment file", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-cache-hit-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Attachment materialization cache hit", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "readme-cache.txt", + contentType: "text/plain", + contentBase64: Buffer.from("Cached attachment body", "utf8").toString("base64"), + })).attachment; + + const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把附件内容写进 README.md", + attachmentIds: [attachment.id], + }); + assert.equal(first.autoStartedWorkflow.workflowSteps.length, 1); + assert.equal(runner.runs.length, 1); + + const store = stores.get(cwd); + assert.ok(store); + const rows = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].extractorId, "text_attachment_v1"); + assert.equal(rows[0].status, "available"); + assert.equal(rows[0].contentText, "Cached attachment body"); + + await rm(resolve(store.paths.attachmentsDir, attachment.path), { force: true }); + + const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把附件内容写进 SECOND.md", + attachmentIds: [attachment.id], + }); + assert.equal(second.autoStartedWorkflow.workflowSteps.length, 1); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[1].task.prompt, /Cached attachment body/); + const afterCacheHit = store.listChannelInputMaterializations({ + channelId: channel.id, + sourceKind: "attachment", + sourceId: attachment.id, + }); + assert.equal(afterCacheHit.length, 1); + assert.equal(afterCacheHit[0].status, "available"); + assert.equal(afterCacheHit[0].contentText, "Cached attachment body"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("attachment-dependent README write blocks when attachment content is not materialized", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-blocked-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Attachment README blocked", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + const oversized = "x".repeat(20 * 1024); + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "large-readme-source.txt", + contentType: "text/plain", + contentBase64: Buffer.from(oversized, "utf8").toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把附件内容写进 README.md", + attachmentIds: [attachment.id], + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.runs.length, 0); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "blocked"); + assert.match(proposals.proposals[0].errorMessage, /可读的附件内容|readable attachment content/i); + assert.doesNotMatch(proposals.proposals[0].errorMessage, /附件预览|attachment preview|too large|previewStatus/i); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "execute"); + assert.match(decisions.decisions[0].reason, /no available attachment preview\/extraction/i); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("unreadable screenshot task creates Ling materialization clarify reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-screenshot-materialization-clarify-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Screenshot materialization clarify", + status: "active", + boundPath: cwd, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "edit"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "screenshot.png", + contentType: "image/png", + contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "根据这个截图修 UI", + attachmentIds: [attachment.id], + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.runs.length, 0); + assert.match(created.aiReply.body, /没能读取 screenshot\.png|could not read image content/i); + assert.doesNotMatch(created.aiReply.body, /OCR|视觉提取|metadata_only|extractor|SSRF/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 0); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "clarify"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.match(decisions.decisions[0].reason, /Attachment content is not materialized/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("blocked link body request creates Ling materialization clarify reply", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-link-materialization-clarify-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Link materialization clarify", + status: "active", + boundPath: cwd, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls"], + })).channel; + const linkPreview = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { + url: "http://127.0.0.1/private", + })).linkPreview; + assert.equal(linkPreview.status, "blocked"); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Please summarize this link", + linkPreviewIds: [linkPreview.id], + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.runs.length, 0); + assert.match(created.aiReply.body, /local\/private links automatically|本机或私有地址/i); + assert.doesNotMatch(created.aiReply.body, /body extraction is blocked|SSRF|metadata_only|extractor/i); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "clarify"); + assert.match(decisions.decisions[0].reason, /Link body materialization is blocked/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("Ling Agent fallback creates file proposal when no Docs teammate can run", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-docs-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent fallback docs", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `在 ${cwd} 下创建 README.md`, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); + assert.equal(proposals.proposals[0].proposedByTeammateId, "ling_agent"); + assert.deepEqual(proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), ["ling_agent"]); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); + + assert.equal(created.autoStartedWorkflow.proposal.status, "accepted"); + assert.equal(created.autoStartedWorkflow.workflow.createdBy, "ling_agent"); + assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantId, "ling_agent"); + assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantLabel, "Ling Agent"); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["write"]); + assert.match(runner.runs[0].task.prompt, /Assigned teammate: Ling Agent/); + assert.match(runner.runs[0].task.prompt, /Confirmed task-scoped tools:\nwrite/); + const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); + assert.equal(runs.runs.length, 1); + assert.equal(runs.runs[0].teammateId, "ling_agent"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("Ling Agent fallback edits code proposal when no Engineer teammate can run", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-engineer-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Ling Agent fallback engineer", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "edit"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `修改 ${cwd}/src/app.ts,把标题改成 Ling`, + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["edit"]); + assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); + assert.match(proposals.proposals[0].riskSummary, /工程师/); + + assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantId, "ling_agent"); + assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["edit"]); + assert.equal(runner.runs.length, 1); + assert.deepEqual(runner.runs[0].tools, ["edit"]); + assert.match(runner.runs[0].task.prompt, /Assigned teammate: Ling Agent/); + const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); + assert.equal(runs.runs.length, 1); + assert.equal(runs.runs[0].teammateId, "ling_agent"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-step file creation blocks when channel lacks the required write tool", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-channel-readonly-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Read only docs", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `在 ${cwd} 下创建 README.md`, + }); + + assert.equal(created.aiReply.status, "blocked"); + assert.doesNotMatch(created.aiReply.body, /Starting now|现在开始处理/u); + assert.match(created.aiReply.body, /blocked|阻塞/u); + assert.equal(created.autoStartedWorkflow.orchestration.status, "blocked"); + assert.match(created.autoStartedWorkflow.orchestration.blocker, /channel|tools|write/i); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); + assert.ok(proposals.proposals[0].errorMessage || created.autoStartedWorkflow.orchestration.blocker); + assert.match(proposals.proposals[0].riskSummary, /write/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + + assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); + assert.equal(created.autoStartedWorkflow.workflowSteps[0].metadata.sourceProposalId, proposals.proposals[0].id); + assert.equal(runner.runs.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-step file edit inherits target path from recent workflow context", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-continuation-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Docs edit continuation", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "write", "edit"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: `在 ${cwd} 下创建 README.md`, + }); + const createProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(createProposals.proposals.length, 1); + assert.equal(createProposals.proposals[0].status, "accepted"); + + const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "在 README.md 中添加一句测试语句", + }); + + assertCoordinatorWorkflowStartReply(edited); + assert.equal(edited.autoStartedWorkflow.workflowSteps.length, 1); + const editProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); + assert.equal(editProposals.proposals.length, 1); + assert.equal(editProposals.proposals[0].status, "accepted"); + assert.equal(editProposals.proposals[0].linkedWorkflowId, edited.autoStartedWorkflow.workflow.id); + assert.deepEqual(editProposals.proposals[0].requiredTools, ["edit"]); + assert.match(editProposals.proposals[0].title, /README\.md|\.\.\./); + assert.match(editProposals.proposals[0].prompt, new RegExp(`Target: ${escapeRegExp(cwd)}/README\\.md`)); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(edited.message.id)}`); + assert.equal(decisions.decisions[0].action, "execute"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-step file edit normalizes Users path without leading slash", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-users-path-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Docs users path edit", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_docs", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "edit"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Users/tianxiao/ling-stack-test 下面,在 README.md 中添加一句测试语句", + }); + + assertCoordinatorWorkflowStartReply(edited); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.deepEqual(proposals.proposals[0].requiredTools, ["edit"]); + assert.match(proposals.proposals[0].title, /\/Users\/tianxiao\/ling-stack-test\/README\.md/); + assert.match(proposals.proposals[0].prompt, /Target: \/Users\/tianxiao\/ling-stack-test\/README\.md/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); + assert.deepEqual(routeDecisions.decisions, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("file creation how-to question remains ordinary Ling chat intent", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-howto-")); + const daemon = await startDaemonForTest(); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "How-to docs", + status: "active", + boundPath: cwd, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "如何创建 README.md?", + }); + + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(decisions.decisions, created.message.id); + + const whyQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "为什么不能创建 README.md?", + }); + assert.equal(whyQuestion.aiReply.status, "pending"); + assert.equal(whyQuestion.replyDispatch.disposition, "reply_requested"); + assert.equal(whyQuestion.replyDispatch.targetTeammateId, "ling_agent"); + const whyProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(whyQuestion.message.id)}`); + assert.deepEqual(whyProposals.proposals, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("single-step test request becomes direct workflow with bounded bash tools", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-test-")); + const daemon = await startDaemonForTest(); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "QA execution", + status: "active", + boundPath: cwd, + defaultTeammateId: "teammate_builtin_qa", + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash"], + })).channel; + await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "开始执行测试", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_qa"); + assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); + assert.match(proposals.proposals[0].riskSummary, /bash/); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions[0].action, "execute"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("high-risk push merge request auto-starts when required tools are not denied", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Release", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "帮我 push 合并代码", + }); + + assertCoordinatorWorkflowStartReply(created); + assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); + assert.equal(runner.runs.length, 1); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "accepted"); + assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); + assert.match(proposals.proposals[0].riskSummary, /^本次任务需要 bash 权限处理 Git push\/merge workflow。/u); + + const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflows.workflows.length, 1); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => /High-risk side effect is runtime-gated: git_push_or_merge/.test(decision.reason)), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("high-risk thread follow-up creates a proposal without auto-starting", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-high-risk-push-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Release thread", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const previousUser = appendChannelItemFixture(store, + { + channelId: channel.id, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "这个分支后面要发版", + }, + "2026-06-12T09:00:00.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + surface: { kind: "topic", anchorItemId: previousUser.id }, + body: "帮我 push 合并代码", + mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], + requestAiReply: true, + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "draft"); + assert.equal(proposals.proposals[0].linkedWorkflowId, undefined); + assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); + + const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflows.workflows.length, 0); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("high-risk push merge request blocks before run when required tool is denied", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-deny-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/approval-policy?cwd=${encodeURIComponent(cwd)}`, { + toolDecisions: { bash: "deny" }, + }); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Release", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "帮我 push 合并代码", + }); + + assert.equal(created.aiReply, undefined); + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(runner.runs.length, 0); + + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 1); + assert.equal(proposals.proposals[0].status, "draft"); + assert.equal(proposals.proposals[0].linkedWorkflowId, undefined); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.some((decision) => decision.status === "failed" && /denies required tools: bash/.test(decision.errorMessage ?? decision.reason)), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted channel intent no_reply falls through to ordinary Ling replies", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-no-reply-fallthrough-")); + const runner = new ScriptedRunner([ + JSON.stringify({ action: "no_reply", reason: "Not an executable task." }), + "这个问题应该由 Ling Agent 回复。", + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep"], + })).channel; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个实现边界怎么处理?", + }); + + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "这个问题应该由 Ling Agent 回复。"); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); + assert.match(runner.runs[0].task.prompt, /Classify the latest user channel message into exactly one Ling intent/); + assert.match(runner.runs[0].task.prompt, /Available teammate roles JSON/); + assert.match(runner.runs[1].systemPrompt, /Do not include hidden proposal blocks/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(routeDecisions.decisions.length, 1); + assert.equal(routeDecisions.decisions[0].mode, "default_teammate"); + assert.equal(routeDecisions.decisions[0].action, "reply"); + assert.equal(routeDecisions.decisions[0].status, "accepted"); + assert.equal(routeDecisions.decisions[0].replyMessageId, created.aiReply.id); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "no_reply"); + assert.equal(decisions.decisions[0].status, "suppressed"); + assert.equal(decisions.decisions[0].createdBy, "model_policy"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model-assisted channel intent failure cannot become an ordinary reply or confirmable proposal", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-fallback-")); + const runner = new ScriptedRunner([ + "not json", + `普通频道回复。{"title":"Unbound proposal","prompt":"Start three agents","acceptanceCriteria":["done"],"requiredTools":["read"]}`, + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "teammate_builtin_engineer", + autoReplyMode: "default_teammate", + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + })).channel; + for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + } + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Architect 先拆,Engineer 实现,QA 验证", + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.kind, "ai_reply"); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, "channel_intent_selection_failed"); + assert.equal(runner.runs.length, 2, "initial selection and exactly one format repair"); + assert.ok(runner.runs.every(run => /channel intent selector/i.test(run.systemPrompt))); + assert.doesNotMatch(created.aiReply.body, /普通频道回复|ling_task_proposal/); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(proposals.proposals.length, 0); + const allProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); + assert.equal(allProposals.proposals.length, 0); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].status, "failed"); + assert.equal(decisions.decisions[0].errorCode, "channel_intent_selection_failed"); + assert.deepEqual(decisions.decisions[0].resultItemIds, [created.aiReply.id]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("model execution proposal cannot declare command evidence authority", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-command-authority-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "execute", + reason: "Run Node.", + execution: { + schemaVersion: 2, + operations: [{ + ...commandOperationCandidate({ sourceQuote: "运行 node app.js", command: "node app.js" }), + commandMatchPolicy: { kind: "executable", executable: "node" }, + }], + }, + }), + "请确认是否按明确的本地命令执行。", + ]); + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Model command authority", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "bash"], + })).channel; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "运行 node app.js", + }); + + assert.equal(created.autoStartedWorkflow, undefined); + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].action, "reply"); + assert.equal(decisions.decisions[0].status, "failed"); + assert.equal(decisions.decisions[0].errorCode, "channel_operation_plan_invalid"); + assert.equal(decisions.decisions[0].execution, undefined); + assert.equal(runner.runs.length, 2); + const observations = await getJson(`${daemon.url}/control-plane/events?cwd=${encodeURIComponent(cwd)}&domains=runners&limit=1000`); + const rejected = observations.events.filter(event => event.entityType === "channel_intent_selection" + && event.entityId === created.message.id && event.data.outcome === "shape_rejected"); + assert.deepEqual(rejected.map(event => event.data.issue), [{ path: "execution.operations[]", code: "unexpected_fields" }]); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.deepEqual(proposals.proposals, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("issue #421 article continuation stays reply while an explicit README save executes", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-article-")); + // file_create needs an absent target; the generic channel test helper seeds a + // README.md for its Git baseline, which a real exclusive create must reject. + await ensureEmptyGitRepo(cwd); + let continuedReply; + let selectorCalls = 0; + let replies = 0; + const daemon = await startRealPiChannelFixture(cwd, (request) => { + if (/channel intent selector/i.test(systemText(request))) { + selectorCalls += 1; + if (selectorCalls <= 2) return { text: JSON.stringify({ action: "reply", + reason: selectorCalls === 1 ? "The user asked for conversational article content." : "The user confirmed that the article should continue in chat." }) }; + return { text: JSON.stringify({ + action: "execute", + reason: "The user explicitly requested a local README write.", + execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", path: "README.md", + sourceQuote: "把刚才的文章保存到 README.md。", + contentSource: { kind: "literal", text: continuedReply.body, sourceQuote: continuedReply.body, sourceItemId: continuedReply.id } })] }, + }) }; + } + if (selectorCalls <= 2) { + replies += 1; + return { text: replies === 1 ? "提纲:一、可恢复记忆;二、选择性投影;三、多 Agent 一致性。" + : "正文:可恢复记忆的关键不是无限拼接历史,而是保留可追溯事实并按当前任务投影。" }; + } + if (!request.messages.some((message) => message.role === "tool")) return { tool: "write", input: { path: "README.md", content: continuedReply.body } }; + return { text: "README.md saved." }; + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channelResponse = await fetch(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ + name: "Issue 421 article continuity", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + }), + }); + const channelPayload = await channelResponse.json(); + assert.equal(channelResponse.ok, true, JSON.stringify(channelPayload)); + const channel = channelPayload.channel; + await assert.rejects(readFile(resolve(cwd, "README.md")), /ENOENT/); + + const article = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "帮我写一篇关于可恢复多 Agent 记忆的长文,先给提纲。", + teammateId: "ling_agent", + requestAiReply: true, + }); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, article.aiReply.id, "ready"); + + const continuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "需要,开始吧", + teammateId: "ling_agent", + requestAiReply: true, + }); + const continuedMessages = await waitForChannelMessageStatus( + daemon.url, + cwd, + channel.id, + continuation.aiReply.id, + "ready", + ); + continuedReply = continuedMessages.messages.find((message) => message.id === continuation.aiReply.id); + assert.match(continuedReply.body, /可恢复记忆/); + + const articleDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(article.message.id)}`); + const continuationDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(continuation.message.id)}`); + assertSingleReplyIntentDecision(articleDecisions.decisions, article.message.id); + assertSingleReplyIntentDecision(continuationDecisions.decisions, continuation.message.id); + assert.equal(articleDecisions.decisions[0].createdBy, "model_policy"); + assert.equal(continuationDecisions.decisions[0].createdBy, "model_policy"); + assert.equal(continuationDecisions.decisions[0].contextSegmentId, articleDecisions.decisions[0].contextSegmentId); + assert.equal(continuationDecisions.decisions[0].clarification, undefined); + assert.equal(continuationDecisions.decisions[0].execution, undefined); + const continuationProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(continuation.message.id)}`); + assert.deepEqual(continuationProposals.proposals, []); + + const save = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把刚才的文章保存到 README.md。", + teammateId: "ling_agent", + requestAiReply: true, + }); + assertCoordinatorWorkflowStartReply(save); + assert.equal(save.autoStartedWorkflow.workflowSteps.length, 1); + const saveProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(save.message.id)}`); + assert.equal(saveProposals.proposals.length, 1); + assert.equal(saveProposals.proposals[0].status, "accepted"); + assert.match(saveProposals.proposals[0].prompt, /create_file: file_create \(README\.md\)/); + const saveDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(save.message.id)}`); + assert.equal(saveDecisions.decisions.length, 2); + assert(saveDecisions.decisions.every((decision) => decision.action === "execute")); + assert(saveDecisions.decisions.every((decision) => decision.execution.operations[0].kind === "file_create")); + assert(saveDecisions.decisions.every((decision) => decision.execution.operations[0].targetKind === "file")); + assert(saveDecisions.decisions.every((decision) => decision.proposalId === saveProposals.proposals[0].id)); + assert(saveDecisions.decisions.every((decision) => { + assertIntentDecisionInspectPayloadIsSafe(decision); + return true; + })); + const classified = saveDecisions.decisions.find((decision) => decision.createdBy === "model_policy"); + const started = saveDecisions.decisions.find((decision) => decision.workflowId === save.autoStartedWorkflow.workflow.id); + assert.ok(classified); + assert.ok(started); + assert.equal(classified.execution.operations[0].binding.contentSource.text, continuedReply.body); + assert.deepEqual(classified.execution.operations[0].binding.contentSource.sourceRefs.map((ref) => ref.itemId), [continuedReply.id]); + assert.deepEqual(started.resultItemIds, [save.aiReply.id]); + assert.equal(replies, 2); + assert.equal(daemon.selectorRequests.length, 3); + await daemon.assertWorkflowCompleted(save, { files: [{ path: "README.md", content: continuedReply.body }], + actionTypes: ["write"], participantIds: ["ling_agent"] }); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("issue #421 unrelated snake-game request escapes an old target clarification", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-segment-reset-")); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "clarify", + reason: "A local write needs a concrete destination.", + clarification: { missingFields: ["target"], question: "要保存到哪个目标文件?" }, + }), + JSON.stringify({ action: "reply", reason: "The snake-game design is independently answerable in chat." }), + "贪吃蛇玩法:方向键移动,吃到食物增长,撞墙或撞到自己结束。", + ]); + runner.segmentationResponses = [JSON.stringify({ + action: "propose_new", + confidence: 0.96, + relatedSegmentIds: [], + reason: "The snake-game design is unrelated to the unresolved file destination.", + })]; + const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Issue 421 clarification isolation", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "mentions_only", + allowedTools: ["read", "grep", "find", "ls", "write"], + })).channel; + + const oldRequest = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "把这段内容写到本地文件里。", + teammateId: "ling_agent", + requestAiReply: true, + }); + assert.equal(oldRequest.aiReply.status, "ready"); + assert.match(oldRequest.aiReply.body, /目标文件/); + const oldDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(oldRequest.message.id)}`); + assert.equal(oldDecisions.decisions.length, 1); + assert.equal(oldDecisions.decisions[0].action, "clarify"); + assert.deepEqual(oldDecisions.decisions[0].clarification.missingFields, ["target"]); + + const snake = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "再做一个贪吃蛇小游戏的方案,先讲玩法。", + teammateId: "ling_agent", + requestAiReply: true, + }); + const snakeMessages = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, snake.aiReply.id, "ready"); + const snakeReply = snakeMessages.messages.find((message) => message.id === snake.aiReply.id); + assert.match(snakeReply.body, /贪吃蛇玩法/); + assert.doesNotMatch(snakeReply.body, /目标文件|target file/i); + const snakeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(snake.message.id)}`); + assertSingleReplyIntentDecision(snakeDecisions.decisions, snake.message.id); + assert.equal(snakeDecisions.decisions[0].createdBy, "model_policy"); + assert.notEqual(snakeDecisions.decisions[0].contextSegmentId, oldDecisions.decisions[0].contextSegmentId); + assert.equal(runner.segmentationRuns.length, 1); + const snakeProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(snake.message.id)}`); + assert.deepEqual(snakeProposals.proposals, []); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("default_teammate route suppresses duplicate Ling Agent replies for the same source message", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-idempotent-")); + const now = "2026-06-09T00:00:00.000Z"; + const store = createSqliteRunStore(cwd); + try { + const provider = store.createProviderPreset( + { + kind: "glm", + displayName: "Static Chat", + providerId: "static-chat", + model: "static-model", + api: "google-generative-ai", + capabilities: ["chat"], + input: ["text"], + authHeader: false, + enabled: true, + }, + now, + ); + store.recordProviderCheckResult(provider.id, { + ok: true, + code: "static_check", + message: "Provider configuration is valid.", + checkedAt: now, + }); + const channel = store.createChannel( + { + name: "Engineering", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "engineer", + autoReplyMode: "default_teammate", + }, + now, + ); + const sourceMessage = appendChannelItemFixture(store, + { + channelId: channel.id, + authorType: "local-user", + body: "这个问题应该怎么回复?", + }, + now, + ); + appendChannelItemFixture(store, + { + channelId: channel.id, + replyToItemId: sourceMessage.id, + kind: "ai_reply", + authorType: "ai-teammate", + authorId: "ling_agent", + body: "Existing reply.", + }, + now, + ); + + const route = await routeChannelMessageReply({ + store, + channel, + sourceMessage, + config: testDaemonConfig(cwd), + secretResolver: createCompositeSecretResolver({ env: {} }), + runnerRegistry: unusedRunnerRegistry(), + }); + + assert.equal(route.action, "no_reply"); + assert.equal(route.reason, "An AI reply already exists for this source message."); + const decisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: sourceMessage.id }); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].status, "suppressed"); + assert.equal(decisions[0].reason, "An AI reply already exists for this source message."); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("default_teammate route treats channel branches and topic replies as separate surfaces", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-surface-route-")); + const now = "2026-06-25T00:00:00.000Z"; + const store = createSqliteRunStore(cwd); + try { + const provider = store.createProviderPreset( + { + kind: "glm", + displayName: "Static Chat", + providerId: "static-chat", + model: "static-model", + api: "google-generative-ai", + capabilities: ["chat"], + input: ["text"], + authHeader: false, + enabled: true, + }, + now, + ); + store.recordProviderCheckResult(provider.id, { + ok: true, + code: "static_check", + message: "Provider configuration is valid.", + checkedAt: now, + }); + const channel = store.createChannel( + { + name: "Engineering", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "engineer", + autoReplyMode: "default_teammate", + }, + now, + ); + const rootThread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Root", + objective: "Root conversation", + }, now).thread; + const rootMessage = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body: "Create a branch", + }, now); + const rootItem = store.getConversationItem(rootMessage.id); + assert.ok(rootItem?.turnId); + const rootAssignment = store.getEffectiveTurnContextAssignment(rootItem.turnId); + assert.ok(rootAssignment); + const branchThread = store.resolveOrCreateConversationThread({ + id: "thread_branch_root", + surfaceKind: "branch", + channelId: channel.id, + parentThreadId: rootThread.id, + parentTurnId: rootItem.turnId, + title: "Branch", + objective: "Branch conversation", + forkContextSnapshot: { + id: "snapshot_branch_root", + segmentId: rootAssignment.segmentId, + projectionVersion: "ctxv2_branch_root", + throughItemSequence: rootItem.sequence, + summaryIds: [], + sourceRefs: [{ type: "conversation_item", id: rootItem.id }], + policyVersion: "continuity-v2-snapshot-v1", + }, + }, now).thread; + const branch = store.createChannelBranch( + { + channelId: channel.id, + name: "test", + rootThreadId: branchThread.id, + }, + now, + ); + const branchMessage = appendChannelItemFixture(store, + { + channelId: channel.id, + threadId: branch.rootThreadId, + authorType: "local-user", + body: "分支里也需要继续回复。", + }, + now, + ); + const topicSource = appendChannelItemFixture(store, + { + channelId: channel.id, + authorType: "local-user", + body: "主频道源消息。", + }, + now, + ); + const topicReply = appendChannelItemFixture(store, + { + channelId: channel.id, + topicAnchorItemId: topicSource.id, + authorType: "local-user", + body: "这是 topic reply,不应该自动拉起 agent。", + }, + now, + ); + + const sharedRouteInput = { + store, + channel, + config: testDaemonConfig(cwd), + secretResolver: createCompositeSecretResolver({ env: {} }), + runnerRegistry: unusedRunnerRegistry(), + }; + const branchRoute = await routeChannelMessageReply({ + ...sharedRouteInput, + sourceMessage: branchMessage, + }); + const topicRoute = await routeChannelMessageReply({ + ...sharedRouteInput, + sourceMessage: topicReply, + }); + + assert.equal(branchRoute.action, "reply"); + assert.equal(branchRoute.teammateId, "ling_agent"); + assert.equal(branchRoute.reason, "Ling Agent answered a channel branch message."); + assert.equal(topicRoute.action, "no_reply"); + assert.equal(topicRoute.reason, "Topic replies are outside the first auto-reply scope."); + + const branchDecisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: branchMessage.id }); + assert.equal(branchDecisions.length, 1); + assert.equal(branchDecisions[0].action, "reply"); + assert.equal(branchDecisions[0].status, "accepted"); + const topicDecisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: topicReply.id }); + assert.equal(topicDecisions.length, 1); + assert.equal(topicDecisions[0].action, "no_reply"); + assert.equal(topicDecisions[0].status, "suppressed"); + } finally { + store.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("Ling Agent replies to greetings, repeated greetings, punctuation, and acknowledgements", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-greeting-")); + const runner = new ScriptedRunner([ + "你好,我在。可以继续说你的问题。", + "我在,继续说。", + "晚上好,我在。", + "看到了,继续说。", + "收到,继续。", + ]); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + defaultTeammateId: "engineer", + autoReplyMode: "mentions_only", + allowedTools: ["read"], + })).channel; + + const preConfigGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "你好?", + }); + assert.equal(preConfigGreeting.aiReply.status, "pending"); + const preConfigListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, preConfigGreeting.aiReply.id, "ready"); + const preConfigReply = preConfigListed.messages.find((message) => message.id === preConfigGreeting.aiReply.id); + assert.equal(preConfigReply.body, "你好,我在。可以继续说你的问题。"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + autoReplyMode: "default_teammate", + }); + + const repeatedGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "你好?", + }); + assert.equal(repeatedGreeting.aiReply.status, "pending"); + const repeatedListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, repeatedGreeting.aiReply.id, "ready"); + const repeatedReply = repeatedListed.messages.find((message) => message.id === repeatedGreeting.aiReply.id); + assert.equal(repeatedReply.body, "我在,继续说。"); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(repeatedGreeting.message.id)}`); + assert.equal(decisions.decisions[0].action, "reply"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].replyMessageId, repeatedGreeting.aiReply.id); + + const eveningGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "晚上好", + }); + assert.equal(eveningGreeting.aiReply.status, "pending"); + const eveningListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, eveningGreeting.aiReply.id, "ready"); + const eveningReply = eveningListed.messages.find((message) => message.id === eveningGreeting.aiReply.id); + assert.equal(eveningReply.body, "晚上好,我在。"); + + const punctuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "。。", + }); + assert.equal(punctuation.aiReply.status, "pending"); + const punctuationListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, punctuation.aiReply.id, "ready"); + const punctuationReply = punctuationListed.messages.find((message) => message.id === punctuation.aiReply.id); + assert.equal(punctuationReply.body, "看到了,继续说。"); + + const acknowledgement = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "好的", + }); + assert.equal(acknowledgement.aiReply.status, "pending"); + const acknowledgementListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, acknowledgement.aiReply.id, "ready"); + const acknowledgementReply = acknowledgementListed.messages.find((message) => message.id === acknowledgement.aiReply.id); + assert.equal(acknowledgementReply.body, "收到,继续。"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("default_teammate creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-suppressed-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + simulateLegacyChannelWithoutModel(cwd, channel.id); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个问题谁来回复?", + }); + + assert.equal(created.aiReply.status, "blocked"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); + const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(blockedReply.body, "AI teammate is not configured for this channel."); + assert.equal(blockedReply.errorMessage, "No provider preset is selected for this channel discussion."); + assert.equal(runner.runs.length, 0); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].mode, "default_teammate"); + assert.equal(decisions.decisions[0].action, "reply"); + assert.equal(decisions.decisions[0].status, "failed"); + assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("explicit mentions keep reply text but cannot create an unbound proposal", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-route-")); + const runner = new ScriptedRunner([ + `Visible explicit reply{"title":"Explicit proposal","prompt":"Create explicit task","acceptanceCriteria":["ok"],"requiredTools":["read"],"riskSummary":"explicit"}`, + ]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer 这个明确任务可以处理吗?", + mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], + requestAiReply: true, + }); + + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Visible explicit reply"); + assert.match(runner.runs[0].systemPrompt, /Do not include hidden proposal blocks/); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions[0].createdBy, "explicit_mention"); + assert.equal(decisions.decisions[0].status, "accepted"); + const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); + assert.equal(proposals.proposals.length, 0); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auto_route is rejected by channel create and update while the feature flag is disabled", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-disabled-")); + let channelStore; + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + if (storeCwd === cwd) { + channelStore = store; + } + return store; + }, + }, { channels: { autoRoute: false, intentSelector: false } }); + try { + const createUrl = `${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`; + const createBody = { + name: "Engineering", + boundPath: cwd, + autoReplyMode: "auto_route", + }; + const preparedCreateBody = await prepareChannelCreateBody(createUrl, createBody); + await ensureGitRepoForChannelPost(createUrl, preparedCreateBody); + const createResponse = await fetch(createUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(preparedCreateBody), + }); + assert.equal(createResponse.status, 400); + assert.equal((await createResponse.json()).error, "auto_route_disabled"); + + const channel = await createChannel(daemon.url, cwd); + const updateResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ autoReplyMode: "auto_route" }), + }); + assert.equal(updateResponse.status, 400); + assert.equal((await updateResponse.json()).error, "auto_route_disabled"); + + assert.ok(channelStore); + channelStore.updateChannel(channel.id, { autoReplyMode: "auto_route" }, new Date().toISOString()); + const renameResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Renamed", autoReplyMode: "auto_route" }), + }); + assert.equal(renameResponse.status, 200); + const renamed = (await renameResponse.json()).channel; + assert.equal(renamed.name, "Renamed"); + assert.equal(renamed.autoReplyMode, "auto_route"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auto_route uses the model selector and records accepted route evidence", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-model-")); + const stores = new Map(); + const runner = new ScriptedRunner([ + JSON.stringify({ + action: "reply", + teammateId: "teammate_builtin_designer", + confidence: 0.9, + reason: "Designer owns UI wording.", + alternativesConsidered: [ + { + teammateId: "teammate_builtin_designer", + label: "Designer", + score: 0.9, + reason: "Best fit.", + }, + ], + }), + `{"title":"Should not persist","prompt":"Create auto task","acceptanceCriteria":["never"],"requiredTools":["read"],"riskSummary":"blocked by proposalMode"}Designer auto route reply`, + ]); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }, { channels: { autoRoute: true } }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Design", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "auto_route", + allowedTools: ["read"], + })).channel; + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + const store = stores.get(cwd); + assert.ok(store); + const oldGuidanceMessage = appendChannelItemFixture(store, + { + channelId: channel.id, + authorType: "local-user", + body: "排队引导:旧频道引导不应继续影响后续消息。", + }, + "2026-06-12T08:00:00.000Z", + ); + const oldRun = store.createRun({ + id: "run_old_channel_guidance", + cwd, + taskId: "task_old_channel_guidance", + channelId: channel.id, + teammateId: "ling_agent", + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_old_channel_guidance", title: "Old active work", prompt: "Old active work." }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd, task: { id: "task_old_channel_guidance", title: "Old active work", prompt: "Old active work.", channelId: channel.id } }, + createdAt: "2026-06-12T08:00:01.000Z", + }); + store.createRunIntervention( + { + sourceRunId: oldRun.id, + mode: "queue_followup", + instruction: oldGuidanceMessage.body, + requestedBy: "local-user", + idempotencyKey: `channel_message:${oldGuidanceMessage.id}:queue_followup`, + }, + "2026-06-12T08:00:02.000Z", + ); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个设置文案应该怎么改?", + }); + + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Designer auto route reply"); + assert.doesNotMatch(finalReply.body, /ling_task_proposal/); + assert.equal(runner.runs.length, 2); + assert.match(runner.runs[0].systemPrompt, /channel message router/i); + assert.match(runner.runs[0].task.prompt, /Decide whether one AI teammate should reply/); + assert.match(runner.runs[0].task.prompt, /Root local-user messages should produce a visible Ling outcome/); + assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); + assert.match(runner.runs[0].task.prompt, /Auto reply mode: auto_route \(source=channel_setting\)/); + assert.match(runner.runs[0].task.prompt, /Candidates JSON/); + assert.doesNotMatch(runner.runs[0].task.prompt, /旧频道引导不应继续影响后续消息/); + assert.match(runner.runs[1].systemPrompt, /Do not include hidden proposal blocks/); + + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions.length, 1); + assert.equal(decisions.decisions[0].mode, "auto_route"); + assert.equal(decisions.decisions[0].createdBy, "model"); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].selectedTeammateId, "ling_agent"); + assert.equal(decisions.decisions[0].confidence, 0.9); + assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); + assert.equal(decisions.decisions[0].alternativesConsidered[0].teammateId, teammateId); + assert.equal(decisions.decisions[0].contextPack.kind, "auto_route_selector"); + assert.deepEqual(decisions.decisions[0].contextPack.sectionIds, [ + "auto_route_instructions", + "channel_collaboration_protocol", + "auto_route_source_message", + "auto_route_candidates", + ]); + assert.deepEqual(decisions.decisions[0].contextPack.sourceRefCounts, { + channel: 1, + message: 1, + thread: 1, + turn: 1, + conversation_item: 1, + }); + const createdItem = store.getConversationItem(created.message.id); + assert.ok(createdItem?.turnId); + assert.equal(decisions.decisions[0].contextPack.contextProjection.threadId, createdItem.threadId); + assert.equal(decisions.decisions[0].contextPack.contextProjection.turnId, createdItem.turnId); + assert.equal(typeof decisions.decisions[0].contextPack.contextProjection.projectionVersion, "string"); + assert.equal(decisions.decisions[0].contextPack.providerPresetId, provider.id); + assert.deepEqual((await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`)).proposals, []); + assert.deepEqual((await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks, []); + const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); + const replyRuns = runs.runs.filter((run) => run.id !== oldRun.id); + assert.equal(replyRuns.length, 1); + assert.equal(replyRuns[0].id, finalReply.linkedRunId); + assert.equal(replyRuns[0].status, "succeeded"); + assert.equal(replyRuns[0].taskId, created.message.id); + const persistedReplyRun = store.getRun(replyRuns[0].id); + assert.equal( + persistedReplyRun.requestSnapshot.contextProjection.projectionVersion, + decisions.decisions[0].contextPack.contextProjection.projectionVersion, + ); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("auto_route falls back to Ling Agent replies on selector failure", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-fallback-")); + const runner = new ScriptedRunner(["not json", "Ling fallback reply 1"]); + const daemon = await startDaemonForTest(runner, {}, { channels: { autoRoute: true } }); + try { + const channel = await createAutoRouteTestChannel(daemon.url, cwd); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个交给谁回复?", + }); + + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, "ling_agent"); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + assert.equal(runner.runs.length, 2); + let decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].createdBy, "model"); + assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); + + runner.responses.push(JSON.stringify({ + action: "reply", + teammateId: "teammate_builtin_designer", + confidence: 0.2, + reason: "Unsure.", + alternativesConsidered: [], + }), "Ling fallback reply 2"); + const lowConfidence = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个 UI 问题可以自动判断吗?", + }); + assert.equal(lowConfidence.aiReply.status, "pending"); + assert.equal(lowConfidence.aiReply.authorId, "ling_agent"); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, lowConfidence.aiReply.id, "ready"); + decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(lowConfidence.message.id)}`); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.match(decisions.decisions[0].reason, /Route model confidence was below threshold/); + + runner.responses.push(`Here is the decision: ${JSON.stringify({ + action: "reply", + teammateId: "teammate_builtin_designer", + confidence: 0.9, + reason: "Wrapped JSON should fail strict parsing.", + alternativesConsidered: [], + })}`, "Ling fallback reply 3"); + const wrappedJson = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个带解释的模型输出应该失败吗?", + }); + assert.equal(wrappedJson.aiReply.status, "pending"); + assert.equal(wrappedJson.aiReply.authorId, "ling_agent"); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, wrappedJson.aiReply.id, "ready"); + decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(wrappedJson.message.id)}`); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); + + runner.responses.push(JSON.stringify({ + action: "reply", + teammateId: "teammate_missing", + confidence: 0.9, + reason: "Unknown teammate should fail closed.", + alternativesConsidered: [], + }), "Ling fallback reply 4"); + const unknownTeammate = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "这个未知 teammate 应该失败吗?", + }); + assert.equal(unknownTeammate.aiReply.status, "pending"); + assert.equal(unknownTeammate.aiReply.authorId, "ling_agent"); + await waitForChannelMessageStatus(daemon.url, cwd, channel.id, unknownTeammate.aiReply.id, "ready"); + decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(unknownTeammate.message.id)}`); + assert.equal(decisions.decisions[0].status, "accepted"); + assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel AI replies return pending message before runner finishes", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ai-reply-pending-")); + const runner = new DelayedRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createChannel(daemon.url, cwd); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const teammateId = "teammate_builtin_designer"; + await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "@Designer async please", + mentions: [{ id: teammateId, label: "Designer", kind: "ai-teammate" }], + requestAiReply: true, + providerPresetId: provider.id, + teammateId, + }); + + assert.equal(created.aiReply.status, "pending"); + await waitForCondition(() => runner.runs.length === 1, "runner to start channel AI reply"); + assert.equal(runner.runs.length, 1); + assert.equal(runner.completed, false); + const pending = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); + assert.equal(pending.messages.find((message) => message.id === created.aiReply.id).status, "pending"); + + runner.resolve("Delayed reply"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "Delayed reply"); + } finally { + runner.resolve("cleanup"); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel attachment upload validates file types and serves content safely", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachments-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createChannel(daemon.url, cwd); + const uploaded = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "brief.txt", + contentType: "text/plain", + contentBase64: Buffer.from("hello channel", "utf8").toString("base64"), + }); + assert.equal(uploaded.attachment.kind, "text"); + assert.equal(uploaded.attachment.fileName, "brief.txt"); + + const content = await fetch(`${daemon.url}/attachments/${encodeURIComponent(uploaded.attachment.id)}/content?cwd=${encodeURIComponent(cwd)}`); + assert.equal(content.status, 200); + assert.equal(await content.text(), "hello channel"); + + const typed = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "payload.txt", + contentType: "text/html", + contentBase64: Buffer.from("", "utf8").toString("base64"), + }); + const typedContent = await fetch(`${daemon.url}/attachments/${encodeURIComponent(typed.attachment.id)}/content?cwd=${encodeURIComponent(cwd)}`); + assert.match(typedContent.headers.get("content-type") ?? "", /^text\/plain/); + + const rejected = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileName: "archive.zip", + contentBase64: Buffer.from("zip").toString("base64"), + }), + }); + assert.equal(rejected.status, 400); + assert.match((await rejected.json()).message, /Unsupported attachment type/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("attachment-only channel turn reaches Ling Agent with metadata and preview", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-only-turn-")); + const runner = new ScriptedRunner(["看到了 brief.txt。你要我总结、写入文件,还是作为当前任务上下文?"]); + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Attachment turn", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "default_teammate", + allowedTools: ["read"], + })).channel; + const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "brief.txt", + contentType: "text/plain", + contentBase64: Buffer.from("hello channel attachment", "utf8").toString("base64"), + })).attachment; + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "", + attachmentIds: [attachment.id], + }); + + assert.equal(created.aiReply.status, "pending"); + assert.equal(created.aiReply.authorId, "ling_agent"); + const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); + const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); + assert.equal(finalReply.body, "看到了 brief.txt。你要我总结、写入文件,还是作为当前任务上下文?"); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Turn envelope/); + assert.match(runner.runs[0].task.prompt, /brief\.txt/); + assert.match(runner.runs[0].task.prompt, /hello channel attachment/); + const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); + assert.equal(routeDecisions.decisions.length, 1); + assert.equal(routeDecisions.decisions[0].action, "reply"); + const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); + assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel asset references must stay within the same channel", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reference-")); + const daemon = await startDaemonForTest(); + try { + const first = await createChannel(daemon.url, cwd); + const second = await createChannel(daemon.url, cwd); + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "source message", + }); + const attachment = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + fileName: "brief.txt", + contentBase64: Buffer.from("brief", "utf8").toString("base64"), + messageId: message.message.id, + }); + const preview = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { + url: "http://localhost:12345/private", + }); + + const crossUpload = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileName: "other.txt", + contentBase64: Buffer.from("other", "utf8").toString("base64"), + messageId: message.message.id, + }), + }); + assert.equal(crossUpload.status, 400); + assert.equal((await crossUpload.json()).error, "message_channel_mismatch"); + + const crossMessage = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: "cross channel reference", + attachmentIds: [attachment.attachment.id], + linkPreviewIds: [preview.linkPreview.id], + }), + }); + assert.equal(crossMessage.status, 400); + assert.equal((await crossMessage.json()).error, "attachment_channel_mismatch"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/test/channel-workflows.test.mjs b/packages/daemon/test/channel-workflows.test.mjs new file mode 100644 index 000000000..6bdf61587 --- /dev/null +++ b/packages/daemon/test/channel-workflows.test.mjs @@ -0,0 +1,2754 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import { createSqliteRunStore } from "../../core/dist/index.js"; +import { + RecordingRunner, + SelectiveFailingRunner, + DelayedRunner, + AbortableDelayedRunner, + ParallelDelayedRunner, + startDaemonForTest, + createParallelWorkflowStep, + customTemplate, + createChannel, + createWorkflowTestChannel, + createStaticChatProvider, + simulateLegacyChannelWithoutModel, + getJson, + getArtifactJson, + waitForCondition, + waitForWorkflowStepStatus, + settleParallelDelayedWorkflow, + waitForWorkflowTemplateStepStatus, + approveWorkflowStepGate, + postJson, + putJson, + patchJson, +} from "./support/channel-workspace-fixture.mjs"; + +test("channel collaboration protocol resolver API patches existing channel fields and reverts to defaults", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-settings-")); + const daemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: false } }); + try { + await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { + responseLanguage: "zh-CN", + }); + const channel = await createChannel(daemon.url, cwd); + + const initial = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`); + assert.equal(initial.protocol.schemaVersion, 1); + assert.equal(initial.protocol.channelId, channel.id); + assert.equal(initial.protocol.responseLanguage, "zh-CN"); + assert.equal(initial.sources.responseLanguage.source, "workspace_setting"); + assert.equal(initial.sources.responseLanguage.channelValue, "workspace"); + assert.equal(initial.sources.responseLanguage.workspaceValue, "zh-CN"); + assert.equal(initial.protocol.autoReplyMode, "mentions_only"); + assert.equal(initial.sources.autoReplyMode.source, "default"); + assert.equal(initial.protocol.defaultTeammateId, undefined); + assert.equal(initial.sources.defaultTeammateId.source, "default"); + assert.equal(initial.storage.mode, "channel_fields_and_protocol_overrides"); + assert.deepEqual(initial.storage.editableFields, ["responseLanguage", "autoReplyMode", "defaultTeammateId", "defaultWorkflowPreference", "visibleMultiReplyMaxParticipants", "privacyRules", "reviewRules"]); + assert.equal(initial.storage.unsupportedFields.length, 0); + + const patched = await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { + responseLanguage: "en-US", + autoReplyMode: "default_teammate", + defaultTeammateId: "teammate_builtin_engineer", + }); + assert.equal(patched.protocol.responseLanguage, "en-US"); + assert.equal(patched.sources.responseLanguage.source, "channel_setting"); + assert.equal(patched.protocol.autoReplyMode, "default_teammate"); + assert.equal(patched.sources.autoReplyMode.source, "channel_setting"); + assert.equal(patched.protocol.defaultTeammateId, "teammate_builtin_engineer"); + assert.equal(patched.sources.defaultTeammateId.source, "channel_setting"); + const channelAfterPatch = (await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`)).channel; + assert.equal(channelAfterPatch.responseLanguage, "en-US"); + assert.equal(channelAfterPatch.autoReplyMode, "default_teammate"); + assert.equal(channelAfterPatch.defaultTeammateId, "teammate_builtin_engineer"); + + const unsupported = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ privacyRules: ["Do not expose private teammate chat."] }), + }); + assert.equal(unsupported.status, 400); + const unsupportedBody = await unsupported.json(); + assert.equal(unsupportedBody.error, "unsupported_protocol_field"); + assert.equal(unsupportedBody.field, "privacyRules"); + + const autoRoute = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ autoReplyMode: "auto_route" }), + }); + assert.equal(autoRoute.status, 400); + assert.equal((await autoRoute.json()).error, "auto_route_disabled"); + + const missingTeammate = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ defaultTeammateId: "missing_teammate" }), + }); + assert.equal(missingTeammate.status, 400); + assert.equal((await missingTeammate.json()).error, "default_teammate_not_found"); + + const reverted = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol/revert?cwd=${encodeURIComponent(cwd)}`, {}); + assert.deepEqual(reverted.revertedFields, ["responseLanguage", "autoReplyMode", "defaultTeammateId", "defaultWorkflowPreference", "visibleMultiReplyMaxParticipants", "privacyRules", "reviewRules"]); + assert.equal(reverted.protocol.responseLanguage, "zh-CN"); + assert.equal(reverted.sources.responseLanguage.source, "workspace_setting"); + assert.equal(reverted.protocol.autoReplyMode, "mentions_only"); + assert.equal(reverted.sources.autoReplyMode.source, "default"); + assert.equal(reverted.protocol.defaultTeammateId, undefined); + const channelAfterRevert = (await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`)).channel; + assert.equal(channelAfterRevert.responseLanguage, undefined); + assert.equal(channelAfterRevert.autoReplyMode, "mentions_only"); + assert.equal(channelAfterRevert.defaultTeammateId, undefined); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel collaboration protocol drafts apply protocol-only overrides with source refs", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-drafts-")); + const daemon = await startDaemonForTest(); + try { + const first = await createChannel(daemon.url, cwd); + const second = await createChannel(daemon.url, cwd); + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Draft a protocol update.", + }); + const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Wrong channel source.", + }); + + const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { + baseVersion: 0, + proposedPatch: { + defaultWorkflowPreference: "architect_engineer_qa", + visibleMultiReplyMaxParticipants: 2, + privacyRules: ["Do not expose private teammate chat."], + reviewRules: ["Require QA evidence before Done."], + }, + reason: "AI suggested a tighter channel protocol.", + createdBy: "ai-draft", + sourceRefs: [{ type: "message", id: message.message.id, label: "Source request" }], + }); + assert.equal(created.draft.status, "draft"); + assert.equal(created.draft.baseVersion, 0); + assert.equal(created.protocol.defaultWorkflowPreference, "manual"); + assert.equal(created.sources.defaultWorkflowPreference.source, "default"); + + let drafts = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}&status=draft`); + assert.deepEqual(drafts.drafts.map((draft) => draft.id), [created.draft.id]); + + const applied = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts/${encodeURIComponent(created.draft.id)}/apply?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(applied.draft.status, "applied"); + assert.equal(applied.draft.appliedProtocolVersion, 1); + assert.equal(applied.protocol.protocolVersion, 1); + assert.equal(applied.protocol.defaultWorkflowPreference, "architect_engineer_qa"); + assert.equal(applied.sources.defaultWorkflowPreference.source, "protocol_override"); + assert.equal(applied.protocol.visibleMultiReplyMaxParticipants, 2); + assert.deepEqual(applied.protocol.privacyRules, ["Do not expose private teammate chat."]); + assert.deepEqual(applied.protocol.reviewRules, ["Require QA evidence before Done."]); + + const stale = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { + baseVersion: 0, + proposedPatch: { visibleMultiReplyMaxParticipants: 3 }, + reason: "Stale update.", + }); + const staleApply = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts/${encodeURIComponent(stale.draft.id)}/apply?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + }); + assert.equal(staleApply.status, 409); + const staleBody = await staleApply.json(); + assert.equal(staleBody.error, "draft_base_version_mismatch"); + assert.equal(staleBody.draft.status, "stale"); + + const crossRef = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + proposedPatch: { visibleMultiReplyMaxParticipants: 4 }, + reason: "Cross-channel source should fail.", + sourceRefs: [{ type: "message", id: otherMessage.message.id }], + }), + }); + assert.equal(crossRef.status, 400); + assert.match((await crossRef.json()).message, /channel/i); + + const reverted = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/revert?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(reverted.protocol.protocolVersion, undefined); + assert.equal(reverted.protocol.defaultWorkflowPreference, "manual"); + assert.equal(reverted.sources.defaultWorkflowPreference.source, "default"); + drafts = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}&status=applied`); + assert.equal(drafts.drafts.some((draft) => draft.id === created.draft.id), true); + + const candidate = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}`, { + baseVersion: 0, + proposedPatch: { reviewRules: ["Keep candidate review separate from skill creation."] }, + reason: "Channel canvas candidate.", + sourceRefs: [{ type: "message", id: message.message.id, label: "Canvas source" }], + }); + assert.equal(candidate.draft.status, "draft"); + const candidates = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}&status=draft&limit=5`); + assert.equal(candidates.drafts.some((draft) => draft.id === candidate.draft.id), true); + + const rejected = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates/${encodeURIComponent(candidate.draft.id)}/review?cwd=${encodeURIComponent(cwd)}`, { + action: "reject", + reason: "Not needed now.", + }); + assert.equal(rejected.draft.status, "cancelled"); + assert.equal(rejected.protocol.protocolVersion, undefined); + + const approvedCandidate = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}`, { + baseVersion: 0, + proposedPatch: { privacyRules: ["Do not turn every channel protocol into a skill."] }, + reason: "Approved channel canvas candidate.", + sourceRefs: [{ type: "message", id: message.message.id, label: "Canvas source" }], + }); + const reviewed = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates/${encodeURIComponent(approvedCandidate.draft.id)}/review?cwd=${encodeURIComponent(cwd)}`, { + action: "approve", + reason: "Matches channel canvas policy.", + }); + assert.equal(reviewed.draft.status, "applied"); + assert.equal(reviewed.protocol.protocolVersion, 1); + assert.deepEqual(reviewed.protocol.privacyRules, ["Do not turn every channel protocol into a skill."]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel collaboration protocol endpoints persist state and reject cross-channel sources", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-api-")); + const daemon = await startDaemonForTest(); + try { + const first = await createChannel(daemon.url, cwd); + const second = await createChannel(daemon.url, cwd); + const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Use manual handoffs for v1.1.", + }); + const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Other channel.", + }); + const task = (await postJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`, { + channelId: first.id, + title: "Implement protocol", + prompt: "Build channel protocol state.", + })).task; + + const emptyBrief = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/brief?cwd=${encodeURIComponent(cwd)}`); + assert.equal(emptyBrief.brief.objective, ""); + + const decision = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { + sourceMessageId: message.message.id, + title: "Manual handoffs", + decision: "Handoffs do not change task state.", + createdBy: "local-user", + }); + const superseded = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { + title: "Manual handoffs update", + decision: "Supersede by creating a new active decision.", + supersedesDecisionId: decision.decision.id, + createdBy: "local-user", + }); + assert.equal(superseded.decision.status, "active"); + const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`); + assert.equal(decisions.decisions.find((item) => item.id === decision.decision.id).status, "superseded"); + + const brief = await patchJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/brief?cwd=${encodeURIComponent(cwd)}`, { + objective: "Ship channel v1.1", + scope: "Collaboration protocol", + acceptanceCriteria: ["Brief persists"], + decisionIds: [superseded.decision.id], + updatedBy: "local-user", + }); + assert.deepEqual(brief.brief.acceptanceCriteria, ["Brief persists"]); + + const handoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { + sourceTaskId: task.id, + fromParticipantId: "tian", + fromParticipantLabel: "Tian", + toParticipantId: "engineer", + toParticipantLabel: "Engineer", + reason: "Need implementation", + requiredInput: "Plan", + expectedOutput: "Patch", + createdBy: "local-user", + }); + await patchJson(`${daemon.url}/handoffs/${encodeURIComponent(handoff.handoff.id)}?cwd=${encodeURIComponent(cwd)}`, { status: "completed" }); + const listedTask = (await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks.find((item) => item.id === task.id); + assert.equal(listedTask.status, "todo"); + + const summary = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`, { + targetType: "task", + targetId: task.id, + sourceMessageIds: [message.message.id], + decisionIds: [superseded.decision.id], + summary: "Build the protocol layer.", + authorType: "local-user", + }); + assert.equal(summary.contextSummary.targetId, task.id); + + const crossSummary = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + targetType: "thread", + targetId: otherMessage.message.id, + sourceMessageIds: [otherMessage.message.id], + summary: "Should fail.", + authorType: "local-user", + }), + }); + assert.equal(crossSummary.status, 400); + assert.match((await crossSummary.json()).message, /channel/i); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("concurrent decision supersession accepts one successor, reuses exact retries, and rejects conflicts", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-decision-supersession-api-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createChannel(daemon.url, cwd); + const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { + title: "Original decision", + decision: "Use protocol A.", + createdBy: "local-user", + }); + const url = `${daemon.url}/channels/${encodeURIComponent(channel.id)}/decisions?cwd=${encodeURIComponent(cwd)}`; + const replacementA = { + title: "Replacement decision", + decision: "Use protocol B.", + rationale: "B is the verified replacement.", + supersedesDecisionId: source.decision.id, + createdBy: "local-user", + }; + const replacementB = { + ...replacementA, + decision: "Use conflicting protocol C.", + }; + const supersede = (body) => fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + for (const unsupportedStatus of ["active", "superseded"]) { + const unsupportedPatch = await fetch(`${daemon.url}/decisions/${encodeURIComponent(source.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: unsupportedStatus }), + }); + assert.equal(unsupportedPatch.status, 400); + const unsupportedPatchBody = await unsupportedPatch.json(); + assert.equal(unsupportedPatchBody.error, "bad_request"); + assert.match(unsupportedPatchBody.message, /status must be rejected/u); + } + + const invalidStatus = await supersede({ + ...replacementA, + status: "rejected", + }); + assert.equal(invalidStatus.status, 400); + const invalidStatusBody = await invalidStatus.json(); + assert.equal(invalidStatusBody.error, "bad_request"); + assert.match(invalidStatusBody.message, /status must be active/u); + + const rejectable = await postJson(url, { + title: "Decision to reject", + decision: "Do not keep this decision.", + createdBy: "local-user", + }); + const activeReject = await fetch(`${daemon.url}/decisions/${encodeURIComponent(rejectable.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "rejected" }), + }); + assert.equal(activeReject.status, 200); + assert.equal((await activeReject.json()).decision.status, "rejected"); + + const attempts = await Promise.all([supersede(replacementA), supersede(replacementB)]); + assert.deepEqual(attempts.map((attempt) => attempt.status).sort(), [201, 409]); + const createdResponse = attempts.find((attempt) => attempt.status === 201); + const conflictResponse = attempts.find((attempt) => attempt.status === 409); + const created = await createdResponse.json(); + const conflict = await conflictResponse.json(); + assert.equal(conflict.error, "decision_supersession_conflict"); + + const retry = await supersede(created.decision.decision === replacementA.decision ? replacementA : replacementB); + assert.equal(retry.status, 200); + assert.equal((await retry.json()).decision.id, created.decision.id); + + const staleReject = await fetch(`${daemon.url}/decisions/${encodeURIComponent(source.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "rejected" }), + }); + assert.equal(staleReject.status, 409); + const staleRejectBody = await staleReject.json(); + assert.equal(staleRejectBody.error, "decision_status_conflict"); + assert.equal(staleRejectBody.decisionId, source.decision.id); + assert.equal(staleRejectBody.expectedStatus, "active"); + assert.equal(staleRejectBody.actualStatus, "superseded"); + + const decisions = await getJson(`${url}&limit=10`); + assert.equal(decisions.decisions.find((decision) => decision.id === source.decision.id).status, "superseded"); + const successors = decisions.decisions.filter((decision) => decision.supersedesDecisionId === source.decision.id); + assert.equal(successors.length, 1); + assert.equal(successors[0].status, "active"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel handoff workflows start steps, create tasks, and expose blockers", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflows-api-")); + const daemon = await startDaemonForTest(); + try { + const first = await createChannel(daemon.url, cwd); + const second = await createChannel(daemon.url, cwd); + const task = (await postJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`, { + channelId: first.id, + title: "Implement workflow", + prompt: "Build v1.2.", + })).task; + const handoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { + sourceTaskId: task.id, + fromParticipantId: "tian", + fromParticipantLabel: "Tian", + toParticipantId: "engineer", + toParticipantLabel: "Engineer", + reason: "Need implementation", + requiredInput: "v1.2 plan", + expectedOutput: "Patch", + createdBy: "local-user", + }); + + const workflowFromHandoffUrl = `${daemon.url}/handoffs/${encodeURIComponent(handoff.handoff.id)}/workflow?cwd=${encodeURIComponent(cwd)}`; + const createFromHandoff = async () => { + const response = await fetch(workflowFromHandoffUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ createdBy: "local-user" }), + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return { status: response.status, payload: await response.json() }; + }; + const attempts = await Promise.all([createFromHandoff(), createFromHandoff()]); + assert.deepEqual(attempts.map((attempt) => attempt.status).sort(), [200, 201]); + const createdWorkflow = attempts.find((attempt) => attempt.status === 201).payload; + const retriedWorkflow = attempts.find((attempt) => attempt.status === 200).payload; + assert.equal(createdWorkflow.workflow.status, "draft"); + assert.equal(createdWorkflow.workflowStep.status, "queued"); + assert.equal(retriedWorkflow.workflow.id, createdWorkflow.workflow.id); + assert.equal(retriedWorkflow.workflowStep.id, createdWorkflow.workflowStep.id); + const handoffWorkflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/workflows?cwd=${encodeURIComponent(cwd)}&limit=10`); + assert.deepEqual(handoffWorkflows.workflows.map((workflow) => workflow.id), [createdWorkflow.workflow.id]); + + const started = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(createdWorkflow.workflowStep.id)}/start?cwd=${encodeURIComponent(cwd)}`, { + mode: "create_task", + taskTitle: "Workflow generated task", + }); + assert.equal(started.status, "started"); + assert.equal(started.mode, "create_task"); + assert.equal(started.workflow.status, "succeeded"); + assert.equal(started.workflowStep.status, "succeeded"); + assert.equal(started.task.title, "Workflow generated task"); + assert.equal(started.task.assigneeTeammateId, "engineer"); + + const workflowEvents = await getJson(`${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=workflow&entityId=${encodeURIComponent(createdWorkflow.workflow.id)}`); + assert.ok(workflowEvents.transitions.some((event) => event.toStatus === "succeeded")); + const stepEvents = await getJson(`${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=workflow_step&entityId=${encodeURIComponent(createdWorkflow.workflowStep.id)}`); + assert.ok(stepEvents.transitions.some((event) => event.toStatus === "succeeded")); + + const secondHandoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { + sourceTaskId: task.id, + fromParticipantId: "tian", + fromParticipantLabel: "Tian", + toParticipantId: "engineer", + toParticipantLabel: "Engineer", + reason: "Need run", + requiredInput: "v1.2 plan", + expectedOutput: "Run", + createdBy: "local-user", + }); + simulateLegacyChannelWithoutModel(cwd, first.id); + const blockedWorkflow = await postJson(`${daemon.url}/handoffs/${encodeURIComponent(secondHandoff.handoff.id)}/workflow?cwd=${encodeURIComponent(cwd)}`, { + createdBy: "local-user", + }); + const blocked = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(blockedWorkflow.workflowStep.id)}/start?cwd=${encodeURIComponent(cwd)}`, { + mode: "create_and_run_task", + }); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.mode, "create_and_run_task"); + assert.equal(blocked.workflowStep.status, "blocked"); + assert.match(blocked.blocker, /provider preset/i); + + const crossWorkflow = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourceHandoffId: handoff.handoff.id, + title: "Cross channel", + objective: "Should fail.", + createdBy: "local-user", + }), + }); + assert.equal(crossWorkflow.status, 400); + assert.match((await crossWorkflow.json()).message, /channel/i); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("manual workflow step start rejects a result after source context changes", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-manual-workflow-stale-context-")); + const runner = new DelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Manual continuity workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const store = stores.get(cwd); + assert.ok(store); + store.updateChannel(channel.id, { defaultProviderPresetId: provider.id }, "2026-08-10T03:00:00.000Z"); + const rootThread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Manual continuity workflow", + objective: "Implement the original continuity objective.", + }, "2026-08-10T03:00:00.000Z").thread; + const sourceItem = store.appendConversationUserTurn({ + channelId: channel.id, + threadId: rootThread.id, + title: "Implement the original continuity objective.", + userInstruction: "Implement the original continuity objective.", + payload: { + body: "Implement the original continuity objective.", + format: "plain_text", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + idempotencyKey: "manual-workflow-stale-context-source", + }, "2026-08-10T03:00:00.000Z").item; + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Manual continuity workflow", + objective: "Implement the original continuity objective.", + status: "ready", + createdBy: "local-user", + metadata: { sourceMessageId: sourceItem.id }, + }, "2026-08-10T03:00:01.000Z"); + const step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + title: "Manual continuity step", + prompt: "Implement the original continuity objective.", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + metadata: { + sourceMessageId: sourceItem.id, + providerPresetId: provider.id, + requiredTools: ["read"], + }, + }, "2026-08-10T03:00:02.000Z"); + + const response = await fetch(`${daemon.url}/workflow-steps/${encodeURIComponent(step.id)}/start?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "create_and_run_task" }), + }); + assert.equal(response.ok, true); + await waitForCondition(() => runner.runs.length === 1, "manual workflow runner start"); + store.appendConversationItem({ + channelId: channel.id, + threadId: sourceItem.threadId, + turnId: sourceItem.turnId, + kind: "user_message", + status: "committed", + actorType: "local_user", + visibility: "channel", + payload: { ...sourceItem.payload, body: "Replace the objective before the manual result commits." }, + supersedesItemId: sourceItem.id, + idempotencyKey: "manual-workflow-stale-context-correction", + }, "2026-08-10T03:00:03.000Z"); + runner.resolve("This result consumed the old objective."); + await response.text(); + + const blockedStep = store.getChannelWorkflowStep(step.id); + assert.equal(blockedStep.status, "blocked"); + assert.ok(blockedStep.taskId); + assert.equal(blockedStep.resultSummary, undefined); + assert.equal(blockedStep.metadata.resultContextReview.status, "rejected_stale"); + assert.equal(store.getChannelWorkflow(workflow.id).status, "blocked"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("queued workflow runs keep private role memory out of durable snapshots", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-queued-workflow-memory-isolation-")); + const runner = new AbortableDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Queued continuity workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const store = stores.get(cwd); + assert.ok(store); + store.updateChannel(channel.id, { defaultProviderPresetId: provider.id }, "2026-08-10T03:10:00.000Z"); + + await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { + body: "Hold the channel runtime slot.", + requestAiReply: true, + providerPresetId: provider.id, + }); + await waitForCondition(() => runner.runs.length === 1, "blocking channel reply start"); + const blockingRun = store.listRuns({ limit: 20 }).find((run) => run.status === "running"); + assert.ok(blockingRun); + + const privateMemory = store.createMemoryObject({ + workspaceId: channel.workspaceId, + scopeType: "teammate", + ownerId: "ling_agent", + title: "Queued continuity objective", + body: "Queued continuity objective private role evidence must stay owner-scoped.", + tags: ["queued-continuity"], + sourceRefs: [], + reviewState: "active", + enabled: true, + sensitivity: "private", + verifyBeforeUse: false, + createdBy: "test", + }, "2026-08-10T03:10:01.000Z"); + const rootThread = store.resolveOrCreateConversationThread({ + surfaceKind: "channel_root", + channelId: channel.id, + title: "Queued continuity workflow", + objective: "Queued continuity objective", + }, "2026-08-10T03:10:02.000Z").thread; + const sourceItem = store.appendConversationUserTurn({ + channelId: channel.id, + threadId: rootThread.id, + title: "Queued continuity objective", + userInstruction: "Queued continuity objective", + payload: { + body: "Queued continuity objective", + format: "plain_text", + mentions: [], + attachmentIds: [], + linkPreviewIds: [], + fileRefs: [], + }, + idempotencyKey: "queued-workflow-memory-source", + }, "2026-08-10T03:10:02.000Z").item; + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Queued continuity workflow", + objective: "Queued continuity objective", + status: "ready", + createdBy: "local-user", + metadata: { sourceMessageId: sourceItem.id }, + }, "2026-08-10T03:10:03.000Z"); + const step = store.createChannelWorkflowStep({ + workflowId: workflow.id, + title: "Queued continuity objective", + prompt: "Queued continuity objective", + toParticipantId: "ling_agent", + toParticipantLabel: "Ling Agent", + metadata: { + sourceMessageId: sourceItem.id, + providerPresetId: provider.id, + requiredTools: ["read"], + }, + }, "2026-08-10T03:10:04.000Z"); + + const response = await fetch(`${daemon.url}/workflow-steps/${encodeURIComponent(step.id)}/start?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: "create_and_run_task" }), + signal: AbortSignal.timeout(10_000), + }); + const queuedResponse = await response.text(); + assert.equal(response.ok, true, queuedResponse); + + const queuedRun = store.listRuns({ status: "queued", limit: 20 }) + .find((run) => run.workflowStepId === step.id); + assert.ok(queuedRun); + assert.equal(queuedRun.requestSnapshot.workflowStepId, step.id); + const queuedStep = store.getChannelWorkflowStep(step.id); + assert.equal(queuedStep.status, "running"); + assert.equal(queuedStep.runId, queuedRun.id); + assert.equal(store.getChannelWorkflow(workflow.id).status, "running"); + assert.doesNotMatch(queuedRun.taskSnapshot.prompt, /private role evidence/u); + assert.doesNotMatch(queuedRun.requestSnapshot.task.prompt, /private role evidence/u); + assert.equal(queuedRun.requestSnapshot.preselectedMemoryUse, undefined); + const workUnit = store.getWorkUnit(queuedRun.workUnitId); + assert.ok(workUnit); + assert.doesNotMatch(workUnit.request.task.prompt, /private role evidence/u); + assert.equal(workUnit.request.preselectedMemoryUse, undefined); + assert.equal( + store.listUsedMemoryEvents({ runId: queuedRun.id, limit: 20 }).some((event) => event.memoryIds.includes(privateMemory.id)), + false, + ); + + runner.resolve("The blocking channel reply is complete."); + await waitForCondition(() => store.getRun(blockingRun.id).status === "succeeded", "blocking channel reply completion"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow handoff intents validate, accept, and stay visible as workflow evidence", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-handoff-intents-")); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const store = stores.get(cwd); + assert.ok(store); + const now = new Date().toISOString(); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Handoff intent workflow", + objective: "Validate workflow handoff intent evidence.", + status: "ready", + createdBy: "local-user", + }, now); + const sourceStep = createParallelWorkflowStep(store, workflow.id, "Source Step", { + branchGroupId: "source", + }); + + const created = await postJson(`${daemon.url}/workflow-handoff-intents?cwd=${encodeURIComponent(cwd)}`, { + channelId: channel.id, + workflowId: workflow.id, + sourceWorkflowStepId: sourceStep.id, + fromParticipantId: "teammate_builtin_architect", + fromParticipantLabel: "Architect", + toParticipantId: "teammate_builtin_engineer", + toParticipantLabel: "Engineer", + reason: "Engineer should implement the validated boundary.", + requiredInput: "Design boundary", + expectedOutput: "Patch", + riskSummary: "No permission is granted by this packet.", + sourceRefs: [ + { type: "workflow", id: workflow.id, label: workflow.title }, + { type: "workflow_step", id: sourceStep.id, label: sourceStep.title }, + ], + createdBy: "orchestrator", + }); + assert.equal(created.handoffIntent.status, "validated"); + assert.equal(created.handoffIntent.validation.targetTeammateExists, true); + assert.equal(created.handoffIntent.validation.targetTeammateActive, true); + + const bypassResponse = await fetch( + `${daemon.url}/workflow-handoff-intents/${encodeURIComponent(created.handoffIntent.id)}?cwd=${encodeURIComponent(cwd)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "accepted" }), + }, + ); + assert.equal(bypassResponse.status, 400); + assert.match(await bypassResponse.text(), /status is server-owned/u); + assert.equal(store.getWorkflowHandoffIntent(created.handoffIntent.id).status, "validated"); + + const stepAfterIntent = store.getChannelWorkflowStep(sourceStep.id); + assert.ok(stepAfterIntent.metadata.handoffIntentIds.includes(created.handoffIntent.id)); + + const accepted = await postJson(`${daemon.url}/workflow-handoff-intents/${encodeURIComponent(created.handoffIntent.id)}/accept?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(accepted.intent.status, "accepted"); + assert.ok(accepted.intent.channelHandoffId); + + const listed = await getJson(`${daemon.url}/workflow-handoff-intents?cwd=${encodeURIComponent(cwd)}&workflowId=${encodeURIComponent(workflow.id)}`); + assert.deepEqual(listed.handoffIntents.map((intent) => intent.id), [created.handoffIntent.id]); + + const evidence = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); + assert.equal(evidence.handoffIntents.some((intent) => intent.id === created.handoffIntent.id && intent.status === "accepted"), true); + assert.equal(evidence.handoffs.some((handoff) => handoff.id === accepted.intent.channelHandoffId && handoff.status === "accepted"), true); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow collaboration card endpoints derive compact product cards", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-collaboration-card-")); + const stores = new Map(); + const daemon = await startDaemonForTest(undefined, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createChannel(daemon.url, cwd); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Ship collaboration card read model", + objective: "Expose a compact collaboration card without raw workflow internals.", + status: "running", + createdBy: "ling_agent", + }, "2026-06-13T00:00:00.000Z"); + const designStep = store.createChannelWorkflowStep({ + workflowId: workflow.id, + position: 1, + title: "Design API boundary", + prompt: "Define the daemon API contract.", + toParticipantId: "teammate_builtin_architect", + toParticipantLabel: "Architect", + status: "succeeded", + metadata: { branchGroupId: "design" }, + }, "2026-06-13T00:01:00.000Z"); + store.updateChannelWorkflowStep(designStep.id, { resultSummary: "Contract boundary is ready." }, "2026-06-13T00:02:00.000Z"); + const implementationStep = store.createChannelWorkflowStep({ + workflowId: workflow.id, + position: 2, + title: "Implement endpoint", + prompt: "Implement the daemon endpoint and client facade.", + toParticipantId: "teammate_builtin_engineer", + toParticipantLabel: "Engineer", + status: "waiting_for_approval", + metadata: { branchGroupId: "implementation", maxParallelSiblings: 2 }, + }, "2026-06-13T00:03:00.000Z"); + const run = store.createRun({ + id: "run_collaboration_card_waiting", + cwd, + taskId: "task_collaboration_card_waiting", + channelId: channel.id, + teammateId: "teammate_builtin_engineer", + workflowStepId: implementationStep.id, + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: "task_collaboration_card_waiting", title: "Implement endpoint" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { cwd }, + createdAt: "2026-06-13T00:04:00.000Z", + }); + store.markRunWaitingForApproval(run.id, "2026-06-13T00:05:00.000Z"); + store.updateChannelWorkflowStep(implementationStep.id, { runId: run.id }, "2026-06-13T00:05:00.000Z"); + store.createApprovalRequest({ + runId: run.id, + taskId: run.taskId, + channelId: channel.id, + actionType: "write", + risk: "medium", + title: "Approve endpoint edit", + description: "Allow editing daemon endpoint files.", + payload: { type: "file_write", path: `${cwd}/packages/daemon/src/features/channels/collaboration-cards.ts`, bytes: 128 }, + expiresAt: "2999-01-01T00:00:00.000Z", + }, "2026-06-13T00:06:00.000Z"); + + const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/collaboration-cards?cwd=${encodeURIComponent(cwd)}`); + assert.equal(listed.channelId, channel.id); + assert.equal(listed.cards.length, 1); + const card = listed.cards[0]; + assert.equal(card.workflowId, workflow.id); + assert.equal(card.mode, "waiting"); + assert.equal(card.executionMode, "parallel"); + assert.equal(card.currentOwnerLabel, "Engineer"); + assert.equal(card.pendingApprovalCount, 1); + assert.equal(card.completedStepCount, 1); + assert.equal(card.totalStepCount, 2); + assert.equal(card.branches.length, 2); + assert.equal(card.branches.some((branch) => branch.ownerLabels.includes("Architect")), true); + assert.equal(card.branches.some((branch) => branch.ownerLabels.includes("Engineer")), true); + assert.equal(JSON.stringify(card).includes("selector"), false); + assert.equal(JSON.stringify(card).includes("contextPack"), false); + + const single = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/collaboration-card?cwd=${encodeURIComponent(cwd)}`); + assert.equal(single.workflowId, workflow.id); + assert.deepEqual(single.card, card); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("autoplan-lite workflow compiles durable steps and orchestrates them through the runner", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-orchestrator-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Autoplan", + status: "active", + boundPath: cwd, + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Build the orchestrator test slice.", + providerPresetId: provider.id, + runnerId: "pi", + createdBy: "local-user", + }); + assert.equal(compiled.workflow.status, "ready"); + assert.equal(compiled.workflowSteps.length, 8); + assert.equal(compiled.workflowSteps[0].metadata.templateStepId, "product-discovery"); + assert.equal(compiled.workflowSteps[0].metadata.requestedRunnerId, "pi"); + assert.equal(compiled.workflowSteps[0].metadata.policy.onRunFailed, "block_for_user"); + assert.equal(compiled.workflowSteps[4].metadata.policy.onRunFailed, "fail_workflow"); + assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds.length, 1); + const firstStepMemory = (await postJson(`${daemon.url}/memory?cwd=${encodeURIComponent(cwd)}`, { + scopeType: "teammate", + ownerId: compiled.workflowSteps[0].toParticipantId, + title: "Orchestrator test objective", + body: "Build the orchestrator test slice with source-linked evidence.", + sensitivity: "private", + })).memory; + + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + assert.equal(firstTick.workflowStep.metadata.templateStepId, "product-discovery"); + const stepsAfterFirstTick = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + const firstStepAfterFirstTick = stepsAfterFirstTick.workflowSteps.find((step) => step.id === firstTick.workflowStep.id); + assert.ok(firstStepAfterFirstTick); + assert.ok(firstStepAfterFirstTick.metadata.contextArtifactId); + const firstContext = await getArtifactJson(daemon.url, cwd, firstStepAfterFirstTick.metadata.contextArtifactId); + assert.equal(firstContext.schemaVersion, 1); + assert.equal(firstContext.workflowId, compiled.workflow.id); + assert.equal(firstContext.workflowStepId, firstStepAfterFirstTick.id); + assert.equal(firstContext.objective, "Build the orchestrator test slice."); + assert.deepEqual(firstContext.dependencies, []); + assert.equal(firstContext.providerPresetId, provider.id); + assert.equal(firstContext.contextPack.kind, "workflow_step"); + assert.equal(firstContext.contextPack.providerPresetId, provider.id); + assert.equal(firstContext.contextPack.budget.mode, "model_estimated"); + assert.equal(firstContext.contextPack.budget.capabilitySource, "provider_metadata"); + assert.equal(firstContext.contextPack.budget.capabilityConfidence, "estimated"); + assert.equal(firstContext.contextPack.overBudget, false); + assert.equal(firstContext.contextPack.sections.some((section) => section.id === "workflow_runtime_boundary"), true); + assert.equal(firstContext.contextPack.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); + const workflowEvidenceAfterFirstTick = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); + const firstStepEvidence = workflowEvidenceAfterFirstTick.steps.find((item) => item.step.id === firstStepAfterFirstTick.id); + assert.ok(firstStepEvidence); + assert.equal(firstStepEvidence.contextInspect.availability, "available"); + assert.equal(firstStepEvidence.contextInspect.contextArtifactId, firstStepAfterFirstTick.metadata.contextArtifactId); + assert.equal(firstStepEvidence.contextInspect.kind, "workflow_step"); + assert.equal(firstStepEvidence.contextInspect.budget.mode, "model_estimated"); + assert.equal(firstStepEvidence.contextInspect.budget.capabilitySource, "provider_metadata"); + assert.equal(firstStepEvidence.contextInspect.budget.capabilityConfidence, "estimated"); + assert.equal(firstStepEvidence.contextInspect.sections.some((section) => section.id === "workflow_runtime_boundary"), true); + assert.equal(firstStepEvidence.contextInspect.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); + assert.equal(firstStepEvidence.contextInspect.safety.rawPromptTextIncluded, false); + assert.doesNotMatch(JSON.stringify(firstStepEvidence.contextInspect), /Workflow step runtime boundary:/); + assert.ok(firstStepAfterFirstTick.runId); + const firstRun = await getJson(`${daemon.url}/runs/${encodeURIComponent(firstStepAfterFirstTick.runId)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(firstRun.workflowStepId, firstStepAfterFirstTick.id); + assert.equal(firstRun.requestSnapshot.workflowStepId, firstStepAfterFirstTick.id); + assert.equal(firstRun.requestSnapshot.runnerResolution.requestSource, "workflow_step"); + assert.match(runner.runs[0].appendSystemPrompt?.join("\n") ?? "", /Build the orchestrator test slice with source-linked evidence/u); + // Public snapshots intentionally omit the private workflow prompt. Keep + // this assertion resilient to the field being absent rather than reading + // the private prompt through an undefined public property. + assert.doesNotMatch(JSON.stringify(firstRun.taskSnapshot), /source-linked evidence/u); + assert.doesNotMatch(JSON.stringify(firstRun.requestSnapshot), /source-linked evidence/u); + assert.equal(firstRun.requestSnapshot.preselectedMemoryUse, undefined); + const firstWorkUnit = await getJson(`${daemon.url}/work-units/${encodeURIComponent(firstRun.workUnitId)}?cwd=${encodeURIComponent(cwd)}`); + assert.doesNotMatch(JSON.stringify(firstWorkUnit.workUnit.request), /source-linked evidence/u); + assert.equal(firstWorkUnit.workUnit.request.preselectedMemoryUse, undefined); + const firstRunMemoryUse = await getJson(`${daemon.url}/memory/used?cwd=${encodeURIComponent(cwd)}&runId=${encodeURIComponent(firstRun.id)}`); + assert.deepEqual(firstRunMemoryUse.events[0].memoryIds, [firstStepMemory.id]); + assert.match(firstRunMemoryUse.events[0].policyVersion, /memory_use_isolation_v2/u); + const runsForStep = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}&workflowStepId=${encodeURIComponent(firstStepAfterFirstTick.id)}`); + assert.deepEqual(runsForStep.runs.map((run) => run.id), [firstRun.id]); + const threadsForStep = await getJson(`${daemon.url}/threads?cwd=${encodeURIComponent(cwd)}&workflowStepId=${encodeURIComponent(firstStepAfterFirstTick.id)}`); + assert.equal(threadsForStep.threads.length, 1); + assert.equal(threadsForStep.threads[0].kind, "workflow_step"); + assert.equal(threadsForStep.threads[0].workflowStepId, firstStepAfterFirstTick.id); + assert.equal(threadsForStep.threads[0].lastRunId, firstRun.id); + + await waitForCondition(() => runner.runs.length === 4, "autoplan-lite planning runner sequence"); + const implementationTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "implementation"); + assert.equal(implementationTick.status, "started", JSON.stringify(implementationTick)); + assert.equal(implementationTick.workflowStep.metadata.templateStepId, "implementation"); + + await waitForCondition(() => runner.runs.length === 6, "autoplan-lite implementation and review sequence"); + const qaTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "qa"); + assert.equal(qaTick.status, "started"); + assert.equal(qaTick.workflowStep.metadata.templateStepId, "qa"); + + await waitForCondition(() => runner.runs.length === 7, "autoplan-lite qa sequence"); + const shipTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "ship"); + assert.equal(shipTick.status, "started"); + assert.equal(shipTick.workflowStep.metadata.templateStepId, "ship"); + + await waitForCondition(() => runner.runs.length === 8, "autoplan-lite runner sequence"); + const finalWorkflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(finalWorkflow.workflow.status, "succeeded"); + const finalSteps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual(finalSteps.workflowSteps.map((step) => step.status), Array(8).fill("succeeded")); + const productStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "product-discovery"); + assert.ok(productStep); + assert.ok(productStep.metadata.outputArtifactId); + assert.ok(productStep.metadata.outputArtifactIds.includes(productStep.metadata.outputArtifactId)); + const productOutput = await getArtifactJson(daemon.url, cwd, productStep.metadata.outputArtifactId); + assert.equal(productOutput.schemaVersion, 1); + assert.equal(productOutput.workflowStepId, productStep.id); + assert.equal(productOutput.runId, productStep.runId); + assert.equal(productOutput.status, "succeeded"); + assert.ok(productOutput.artifactIds.includes(productStep.metadata.contextArtifactId)); + assert.equal(productOutput.runnerAudit.effectiveRunnerId, "pi"); + const designStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "design-review"); + assert.ok(designStep); + assert.ok(designStep.metadata.outputArtifactId); + const implementationStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "implementation"); + assert.ok(implementationStep); + assert.ok(implementationStep.metadata.contextArtifactId); + const implementationContext = await getArtifactJson(daemon.url, cwd, implementationStep.metadata.contextArtifactId); + assert.equal(implementationContext.workflowStepId, implementationStep.id); + assert.equal(implementationContext.dependencies.length, 2); + assert.equal(implementationContext.dependencies.every((dependency) => dependency.outputArtifactId), true); + assert.equal(implementationContext.priorOutputArtifactIds.length >= 2, true); + assert.match(runner.runs[0].task.prompt, /Context Pack: workflow_step/); + assert.match(runner.runs[0].task.prompt, /Provider budget mode: model_estimated/); + assert.equal(firstRun.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependencies"), true); + assert.equal(firstRun.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_approval_gates"), true); + const implementationRun = runner.runs.find((run) => run.task.title === "Implementation"); + assert.ok(implementationRun); + assert.match(implementationRun.task.prompt, /Context Pack: workflow_step/); + assert.match(implementationRun.task.prompt, /Provider budget mode: model_estimated/); + const implementationRunSnapshot = await getJson(`${daemon.url}/runs/${encodeURIComponent(implementationStep.runId)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependency_1"), true); + assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependency_2"), true); + assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_approval_gates"), true); + assert.deepEqual(runner.runs.map((run) => run.task.title), [ + "Product Discovery", + "Product Review", + "Design Review", + "Engineering Review", + "Implementation", + "Code Review", + "QA", + "Ship", + ]); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow graph compiles into durable steps and emits standardized packets", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-graph-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Graph workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + workflowGraph: { + schemaVersion: 1, + id: "graph_test", + name: "Graph Test", + summary: "Graph compile test", + entryNodeId: "plan", + nodes: [ + { + id: "plan", + kind: "step", + name: "Plan", + prompt: "Draft the implementation boundary.", + teammateTemplateId: "architect", + skillId: "spec", + autoStart: true, + }, + { + id: "review", + kind: "step", + name: "Review", + prompt: "Review the implementation boundary.", + teammateTemplateId: "engineer", + skillId: "review", + autoStart: true, + }, + ], + edges: [ + { + id: "plan_to_review", + fromNodeId: "plan", + toNodeId: "review", + condition: { kind: "all_succeeded" }, + }, + ], + termination: { + success: "all_required_succeeded", + onFailedRequiredNode: "fail_workflow", + onApprovalDenied: "block_for_user", + }, + maxParallelSteps: 1, + }, + objective: "Ship graph compile test.", + providerPresetId: provider.id, + runnerId: "pi", + createdBy: "local-user", + }); + assert.equal(compiled.workflow.status, "ready"); + assert.equal(compiled.workflow.metadata.workflowGraphId, "graph_test"); + assert.equal(compiled.workflow.metadata.workflowGraphSchemaVersion, 1); + assert.equal(compiled.workflow.metadata.termination.onFailedRequiredNode, "fail_workflow"); + assert.equal(compiled.workflowSteps.length, 2); + assert.deepEqual(compiled.workflowSteps.map((step) => step.metadata.graphNodeId), ["plan", "review"]); + for (const step of compiled.workflowSteps) { + assert.equal(step.participantAuthority.kind, "workflow_definition"); + assert.equal(step.participantAuthority.origin, "workflow_graph"); + assert.equal(step.participantAuthority.sourceId, "graph_test"); + assert.equal(step.participantAuthority.participantId, step.toParticipantId); + } + assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds.length, 1); + assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds[0], compiled.workflowSteps[0].id); + + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + await waitForCondition(() => runner.runs.length === 2, "workflow graph runner sequence"); + + const finalWorkflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(finalWorkflow.workflow.status, "succeeded"); + const finalSteps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + const planStep = finalSteps.workflowSteps.find((step) => step.metadata.graphNodeId === "plan"); + const reviewStep = finalSteps.workflowSteps.find((step) => step.metadata.graphNodeId === "review"); + assert.ok(planStep); + assert.ok(reviewStep); + assert.ok(planStep.metadata.contextArtifactId); + assert.ok(planStep.metadata.outputArtifactId); + assert.ok(reviewStep.metadata.contextArtifactId); + + const planContext = await getArtifactJson(daemon.url, cwd, planStep.metadata.contextArtifactId); + assert.equal(planContext.graphId, "graph_test"); + assert.equal(planContext.graphNodeId, "plan"); + assert.equal(planContext.teammateId, planStep.toParticipantId); + assert.equal(planContext.skillId, "spec"); + assert.equal(planContext.contextPack.kind, "workflow_step"); + assert.equal(planContext.contextPack.sections.some((section) => section.id === "workflow_current_step"), true); + assert.equal(planContext.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); + + const planOutput = await getArtifactJson(daemon.url, cwd, planStep.metadata.outputArtifactId); + assert.equal(planOutput.graphId, "graph_test"); + assert.equal(planOutput.graphNodeId, "plan"); + assert.equal(planOutput.teammateId, planStep.toParticipantId); + assert.equal(planOutput.skillId, "spec"); + assert.ok(Array.isArray(planOutput.blockers)); + assert.ok(Array.isArray(planOutput.changedPaths)); + assert.equal(planOutput.sourceRefs.some((ref) => ref.type === "run" && ref.id === planStep.runId), true); + + const reviewContext = await getArtifactJson(daemon.url, cwd, reviewStep.metadata.contextArtifactId); + assert.equal(reviewContext.dependencies.length, 1); + assert.equal(reviewContext.dependencies[0].workflowStepId, planStep.id); + assert.equal(reviewContext.dependencies[0].outputArtifactId, planStep.metadata.outputArtifactId); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +for (const contextWindowTokens of [1800, 4096]) { +test(`workflow dependency compaction preserves the SDK budget gate at ${contextWindowTokens} tokens`, async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-context-compaction-")); + const runner = new class extends RecordingRunner { + providerRequests = 0; + + registerCustomProviderIfNeeded(runtime, provider) { + super.registerCustomProviderIfNeeded(runtime, provider); + const streamSimple = runtime.streamSimple.bind(runtime); + runtime.streamSimple = (...args) => { + this.providerRequests += 1; + return streamSimple(...args); + }; + } + }(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + contextWindow: contextWindowTokens, + maxTokens: 512, + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Context compaction workflow", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const now = new Date().toISOString(); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Dependency compaction", + objective: "Verify dependency output compaction before the downstream step starts.", + status: "ready", + createdBy: "local-user", + }, now); + const hugeDependencySummary = `Important dependency summary. ${"research finding detail ".repeat(80)}`; + const fullInputBudget = contextWindowTokens - 512 - 512; + // Measured with the production packer: 20 capped dependencies exceed the + // 4K model's 3072-token packet budget; the compacted summary still fits. + const dependencyCount = contextWindowTokens === 1800 ? 7 : 20; + const upstreamSteps = Array.from({ length: dependencyCount }, (_, index) => { + const step = createParallelWorkflowStep(store, workflow.id, `Research Findings ${index + 1}`, { + branchGroupId: `research-${index + 1}`, + providerPresetId: provider.id, + status: "succeeded", + }); + store.updateChannelWorkflowStep( + step.id, + { resultSummary: hugeDependencySummary }, + now, + ); + return step; + }); + const downstream = createParallelWorkflowStep(store, workflow.id, "Implementation From Research", { + branchGroupId: "implementation", + providerPresetId: provider.id, + dependsOnStepIds: upstreamSteps.map((step) => step.id), + dependencyMode: "all_succeeded", + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + if (contextWindowTokens === 1800) { + // The effective input budget is 776 tokens after output and runtime + // reservations. The complete workflow packet cannot fit, so the + // orchestrator must block before creating a run or calling the provider. + assert.equal(tick.status, "blocked"); + assert.equal(runner.runs.length, 0); + const blockedStep = store.getChannelWorkflowStep(downstream.id); + assert.equal(blockedStep?.status, "blocked"); + assert.match(blockedStep?.blocker ?? "", /exceeds provider budget/); + return; + } + assert.equal(tick.status, "started"); + assert.equal(tick.workflowStep.id, downstream.id); + assert.equal(runner.runs.length, 1); + assert.match(runner.runs[0].task.prompt, /Compacted workflow dependency summary/); + assert.match(runner.runs[0].task.prompt, /Compaction id: workflow_packet_summary_/); + assert.doesNotMatch(runner.runs[0].task.prompt, /research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail/); + + let startedStep; + try { + startedStep = await waitForWorkflowStepStatus(daemon.url, cwd, workflow.id, downstream.id, + contextWindowTokens === 1800 ? "blocked" : "succeeded"); + } catch { + const step = store.getChannelWorkflowStep(downstream.id); + const run = step?.runId ? store.getRun(step.runId) : undefined; + assert.fail(`Compacted workflow did not settle: ${JSON.stringify({ step: step && { status: step.status, + error: step.errorMessage, runId: step.runId }, run: run && { status: run.status, error: run.errorMessage }, + errors: run ? store.listEvents(run.id).filter(event => event.type === "run_error").map(event => event.data) : [] })}`); + } + const run = store.getRun(startedStep.runId); + if (contextWindowTokens === 1800) { + assert.equal(run?.status, "failed"); + assert.match(run.errorMessage, /Pi model context exceeds selected model budget/); + assert.equal(runner.providerRequests, 0, "packet compaction cannot bypass the SDK's complete-input capacity check"); + } else { + assert.equal(run?.status, "succeeded"); + assert.equal(runner.providerRequests, 1, "the actual SDK must admit the compacted input before the no-network reply"); + } + assert.ok(startedStep.metadata.contextArtifactId); + const contextPacket = await getArtifactJson(daemon.url, cwd, startedStep.metadata.contextArtifactId); + assert.equal(contextPacket.contextPack.kind, "workflow_step"); + assert.equal(contextPacket.contextPack.budget.mode, "model_known"); + assert.equal(contextPacket.contextPack.budget.contextWindowTokens, contextWindowTokens); + assert.equal(contextPacket.contextPack.budget.reservedOutputTokens, 512); + assert.equal(contextPacket.contextPack.budget.reservedRuntimeOverheadTokens, 512); + assert.equal(contextPacket.contextPack.budget.inputBudgetTokens, fullInputBudget); + assert.equal(contextPacket.contextPack.budget.tokenizer, "openai-compatible"); + assert.equal(contextPacket.contextPack.budget.capabilitySource, "user_override"); + assert.equal(contextPacket.contextPack.budget.capabilityConfidence, "known"); + assert.equal(typeof contextPacket.contextPack.estimatedInputTokens, "number"); + assert.equal(contextPacket.contextPack.overBudget, false); + assert.equal(contextPacket.contextPack.sections.some((section) => section.id === "workflow_dependency_summary"), true); + assert.equal(contextPacket.contextPack.sections.some((section) => section.id === "workflow_dependency_1"), false); + assert.equal(contextPacket.contextPack.sections.every((section) => typeof section.estimatedTokens === "number"), true); + const protocolSection = contextPacket.contextPack.sections.find((section) => section.id === "channel_collaboration_protocol"); + assert.ok(protocolSection); + assert.equal(protocolSection.metadata.autoReplyMode, "mentions_only"); + assert.equal(protocolSection.metadata.autoReplyModeSource, "default"); + assert.equal(contextPacket.contextPack.droppedSectionIds.includes("workflow_dependency_summary"), false); + assert.equal(contextPacket.contextPack.compactionIds.length, 1); + const compactionArtifactId = contextPacket.contextPack.compactionIds[0]; + assert.equal(contextPacket.contextPack.sourceRefs.some((ref) => ref.type === "artifact" && ref.id === compactionArtifactId), true); + + const compactionPacket = await getArtifactJson(daemon.url, cwd, compactionArtifactId); + assert.equal(compactionPacket.schemaVersion, 1); + assert.equal(compactionPacket.kind, "workflow_packet_summary"); + assert.equal(compactionPacket.workflowId, workflow.id); + assert.equal(compactionPacket.workflowStepId, downstream.id); + assert.equal(compactionPacket.dependencyOutputRefs.length, upstreamSteps.length); + assert.deepEqual(compactionPacket.dependencyOutputRefs.map((ref) => ref.workflowStepId), upstreamSteps.map((step) => step.id)); + assert.equal(compactionPacket.droppedSectionIds.some((id) => id.startsWith("workflow_dependency_")), true); + assert.equal(compactionPacket.sourceRefs.some((ref) => ref.type === "workflow_step" && ref.id === upstreamSteps[0].id), true); + assert.ok(compactionPacket.summary.includes(`Dependency count: ${upstreamSteps.length}.`)); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); +} + +test("workflow planning failure follows policy and blocks for user repair", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-planning-failure-")); + const runner = new SelectiveFailingRunner(["Product Discovery"]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Planning failure workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Block failed planning for repair.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + const failedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, firstTick.workflowStep.id, "blocked"); + assert.equal(failedStep.metadata.policy.onRunFailed, "block_for_user"); + assert.match(failedStep.blocker, /Product Discovery failed/); + const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflow.workflow.status, "blocked"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow implementation failure follows policy and fails the workflow", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-implementation-failure-")); + const runner = new SelectiveFailingRunner(["Implementation"]); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Implementation failure workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Fail implementation when policy requires it.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + await waitForCondition(() => runner.runs.length === 4, "planning and review steps before implementation failure"); + const implementationTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "implementation"); + assert.equal(implementationTick.status, "started", JSON.stringify(implementationTick)); + const failedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "failed"); + assert.equal(failedStep.metadata.policy.onRunFailed, "fail_workflow"); + assert.match(failedStep.blocker, /Implementation failed/); + const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflow.workflow.status, "failed"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow post-step gate pauses downstream steps until approval", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Post gate workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Pause after discovery for review.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const store = stores.get(cwd); + assert.ok(store); + const discoveryStep = store.getChannelWorkflowStep(compiled.workflowSteps[0].id); + assert.ok(discoveryStep); + store.updateChannelWorkflowStep( + discoveryStep.id, + { + metadata: { + ...discoveryStep.metadata, + gates: [{ + stage: "post", + approvalProfile: { mode: "standard", risk: "medium" }, + title: "Review discovery output", + reason: "Review Product Discovery output before downstream planning starts.", + requiredForStatuses: ["succeeded"], + }], + }, + }, + new Date().toISOString(), + ); + + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + const gatedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "waiting_for_approval"); + assert.equal(gatedStep.metadata.activeGateStage, "post"); + assert.ok(gatedStep.metadata.postStepApprovalId); + assert.equal(runner.runs.length, 1); + const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(discoveryStep.id)}`); + assert.equal(pending.approvals.length, 1); + assert.equal(pending.approvals[0].payload.gateStage, "post"); + + const waitingTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(waitingTick.status, "waiting_for_approval"); + assert.equal(runner.runs.length, 1); + await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/approve?cwd=${encodeURIComponent(cwd)}`, { + reason: "Discovery output reviewed.", + }); + const discoveryAfterApproval = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "succeeded"); + assert.equal(discoveryAfterApproval.metadata.activeGateStage, undefined); + assert.ok(discoveryAfterApproval.metadata.outputArtifactId); + const workflowAfterApproval = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflowAfterApproval.workflow.status, "running"); + const nextTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(nextTick.status, "started"); + assert.equal(nextTick.workflowStep.metadata.templateStepId, "product-review"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow post-step gate resumes after daemon restart when approval was granted", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-restart-")); + const runner = new RecordingRunner(); + const stores = new Map(); + let daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Post gate restart workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Resume a granted post gate after daemon restart.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const store = stores.get(cwd); + assert.ok(store); + const discoveryStep = store.getChannelWorkflowStep(compiled.workflowSteps[0].id); + assert.ok(discoveryStep); + store.updateChannelWorkflowStep( + discoveryStep.id, + { + metadata: { + ...discoveryStep.metadata, + gates: [{ + stage: "post", + approvalProfile: { mode: "standard", risk: "medium" }, + title: "Restart review discovery output", + reason: "Review Product Discovery output before restart recovery continues.", + requiredForStatuses: ["succeeded"], + }], + }, + }, + new Date().toISOString(), + ); + + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + const gatedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "waiting_for_approval"); + assert.equal(gatedStep.metadata.activeGateStage, "post"); + assert.ok(gatedStep.metadata.postStepApprovalId); + const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(discoveryStep.id)}`); + assert.equal(pending.approvals.length, 1); + await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/approve?cwd=${encodeURIComponent(cwd)}`, { + reason: "Discovery output approved before daemon restart.", + }); + + await daemon.close(); + daemon = await startDaemonForTest(runner); + + const resumedTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(resumedTick.status, "started"); + assert.equal(resumedTick.workflowStep.metadata.templateStepId, "product-review"); + const discoveryAfterRestart = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "succeeded"); + assert.equal(discoveryAfterRestart.metadata.activeGateStage, undefined); + assert.ok(discoveryAfterRestart.metadata.outputArtifactId); + await waitForCondition(() => runner.runs.length >= 2, "post-gate restart downstream runner start"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow run cancellation follows default policy and blocks for user", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancellation-policy-")); + const runner = new DelayedRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Cancellation policy workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Cancel a running workflow step and block for repair.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + assert.equal(firstTick.workflowStep.metadata.templateStepId, "product-discovery"); + assert.ok(firstTick.runId); + await waitForCondition(() => runner.runs.length === 1, "workflow runner start before run cancellation"); + + const cancelled = await postJson(`${daemon.url}/runs/${encodeURIComponent(firstTick.runId)}/cancel?cwd=${encodeURIComponent(cwd)}`, { + reason: "Cancellation policy regression.", + }); + assert.equal(cancelled.cancelled, true); + const reconciled = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(reconciled.status, "blocked"); + assert.match(reconciled.blocker, /cancelled/i); + const blockedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, firstTick.workflowStep.id, "blocked"); + assert.equal(blockedStep.metadata.policy.onRunCancelled, "block_for_user"); + const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflow.workflow.status, "blocked"); + } finally { + runner.resolve("late completion after cancellation"); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow approval denial follows onApprovalDenied policy and blocks", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-approval-denial-policy-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Approval denial policy workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Deny a pre-step approval and block for user decision.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + await waitForCondition(() => runner.runs.length === 4, "planning and review steps before approval denial"); + const implementationStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "waiting_for_approval"); + const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(implementationStep.id)}`); + assert.equal(pending.approvals.length, 1); + + await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/deny?cwd=${encodeURIComponent(cwd)}`, { + reason: "Implementation plan needs revision.", + }); + const blockedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "blocked"); + assert.equal(blockedStep.metadata.policy.onApprovalDenied, "block_for_user"); + const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflow.workflow.status, "blocked"); + assert.equal(workflow.workflow.blocker, undefined); + const deniedTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(deniedTick.status, "blocked"); + assert.equal(deniedTick.workflowStep.id, implementationStep.id); + assert.match(deniedTick.blocker, /revision/); + assert.equal(runner.runs.length, 4); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow skipped optional step allows downstream steps to start", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-skip-optional-")); + const runner = new SelectiveFailingRunner(["Product Review"]); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Skip optional workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Skip optional product review and continue downstream.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const store = stores.get(cwd); + assert.ok(store); + const productReview = compiled.workflowSteps.find((step) => step.metadata?.templateStepId === "product-review"); + assert.ok(productReview); + store.updateChannelWorkflowStep( + productReview.id, + { + metadata: { + ...productReview.metadata, + policy: { + ...productReview.metadata.policy, + onRunFailed: "skip_if_optional", + }, + }, + }, + new Date().toISOString(), + ); + + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + const skippedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "product-review", "skipped"); + assert.equal(skippedStep.metadata.policy.onRunFailed, "skip_if_optional"); + const downstreamTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(downstreamTick.status, "started"); + assert.equal(downstreamTick.workflowStep.metadata.templateStepId, "design-review"); + await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "design-review", "succeeded"); + await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "engineering-review", "succeeded"); + assert.equal(runner.runs.some((run) => run.task.title === "Design Review"), true); + assert.equal(runner.runs.some((run) => run.task.title === "Engineering Review"), true); + const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflow.workflow.status, "waiting_for_approval"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow orchestrator starts independent parallel branches within channel budget", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-parallel-")); + const runner = new ParallelDelayedRunner(); + let cleanupWorkflowId; + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Parallel workflow", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Parallel branches", + objective: "Run independent read-only branch steps.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + const firstStep = createParallelWorkflowStep(store, workflow.id, "Branch A", { + branchGroupId: "branch-a", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + const secondStep = createParallelWorkflowStep(store, workflow.id, "Branch B", { + branchGroupId: "branch-b", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + const thirdStep = createParallelWorkflowStep(store, workflow.id, "Branch C", { + branchGroupId: "branch-c", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + cleanupWorkflowId = workflow.id; + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.startedSteps.length, 2); + assert.equal(tick.schedulingDecision.effectiveMode, "parallel"); + assert.equal(tick.schedulingDecision.parallelLimit, 2); + assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "read_only_parallel_allowed"]); + assert.deepEqual(tick.startedSteps.map((item) => item.workflowStep.id).sort(), [firstStep.id, secondStep.id].sort()); + assert.deepEqual(tick.schedulingDecision.queuedStepIds, [thirdStep.id]); + await runner.waitUntilStarted(2); + const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual(steps.workflowSteps.map((step) => step.status).sort(), ["queued", "running", "running"]); + } finally { + try { + runner.resolveAll("parallel cleanup"); + if (cleanupWorkflowId) { + await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "parallel cleanup"); + } + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("custom workflow template compiles deterministic steps and dependencies", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-")); + const daemon = await startDaemonForTest(); + try { + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Custom template", + status: "active", + boundPath: cwd, + allowedTools: ["read", "grep", "find", "ls"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + objective: "Compile a custom deterministic workflow.", + providerPresetId: "provider_static", + runnerId: "pi", + customTemplate: customTemplate({ + id: "custom-safe-template", + name: "Custom Safe Template", + maxParallelSteps: 2, + steps: [ + { + id: "research", + name: "Research", + prompt: "Research the problem.", + teammateTemplateId: "architect", + skillId: "plan-eng-review", + branchGroupId: "planning", + }, + { + id: "qa", + name: "QA", + prompt: "Verify the result.", + teammateTemplateId: "designer", + skillId: "plan-design-review", + dependsOn: ["research"], + joinGroupId: "review", + dependencyMode: "all_succeeded", + }, + ], + }), + }); + + assert.equal(compiled.workflow.status, "ready"); + assert.equal(compiled.workflow.title, "Custom Safe Template"); + assert.equal(compiled.workflowSteps.length, 2); + assert.equal(compiled.workflowSteps[0].metadata.workflowTemplateId, "custom-safe-template"); + assert.equal(compiled.workflowSteps[0].metadata.templateVersion, "custom:1"); + assert.equal(compiled.workflowSteps[0].metadata.autoStart, false); + assert.equal(compiled.workflowSteps[0].metadata.maxParallelSiblings, 2); + assert.equal(compiled.workflowSteps[0].toParticipantId, "teammate_builtin_architect"); + assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds[0], compiled.workflowSteps[0].id); + assert.equal(compiled.workflowSteps[1].metadata.joinGroupId, "review"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow councilSlotKey resolves through attached AI council specialists", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-council-slot-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Council slot workflow"); + const developmentCouncilId = "council_builtin_development"; + await putJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/council-policy?cwd=${encodeURIComponent(cwd)}`, { + attachedCouncilIds: [developmentCouncilId], + preferredCouncilId: developmentCouncilId, + }); + + const customCompiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + objective: "Compile a council-backed custom template.", + customTemplate: customTemplate({ + id: "council-slot-template", + name: "Council Slot Template", + steps: [ + { + id: "implementation", + name: "Implementation", + prompt: "Implement the slice.", + councilSlotKey: "engineer", + skillId: "implement", + }, + ], + }), + }); + + const customStep = customCompiled.workflowSteps[0]; + assert.equal(customStep.toParticipantId, "teammate_builtin_engineer"); + assert.equal(customStep.metadata.councilSlotKey, "engineer"); + assert.equal(customStep.metadata.proposalStepSources[0].kind, "council"); + assert.equal(customStep.metadata.proposalStepSources[0].councilId, developmentCouncilId); + assert.equal(customStep.metadata.proposalStepSources[0].slotKey, "engineer"); + + const graphCompiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + objective: "Compile a council-backed workflow graph.", + workflowGraph: { + schemaVersion: 1, + id: "council_slot_graph", + name: "Council Slot Graph", + entryNodeId: "review", + nodes: [ + { + id: "review", + kind: "step", + name: "Review", + prompt: "Review the plan.", + councilSlotKey: "architect", + skillId: "plan-eng-review", + }, + ], + edges: [], + termination: { + success: "all_required_succeeded", + onFailedRequiredNode: "fail_workflow", + onApprovalDenied: "block_for_user", + }, + maxParallelSteps: 1, + }, + }); + + const graphStep = graphCompiled.workflowSteps[0]; + assert.equal(graphStep.toParticipantId, "teammate_builtin_architect"); + assert.equal(graphStep.metadata.councilSlotKey, "architect"); + assert.equal(graphStep.metadata.proposalStepSources[0].kind, "council"); + assert.equal(graphStep.metadata.proposalStepSources[0].councilId, developmentCouncilId); + assert.equal(graphStep.metadata.proposalStepSources[0].slotKey, "architect"); + + const missingSlot = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + objective: "Reject missing council slot.", + customTemplate: customTemplate({ + id: "missing-council-slot-template", + name: "Missing Council Slot Template", + steps: [ + { + id: "missing", + name: "Missing", + prompt: "Use a missing slot.", + councilSlotKey: "missing-slot", + }, + ], + }), + }), + }); + assert.equal(missingSlot.status, 400); + assert.match((await missingSlot.json()).message, /No effective AI council member found/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("custom workflow template rejects cycles and unknown runners at compile time", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-invalid-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createChannel(daemon.url, cwd); + const cyclic = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + objective: "Reject cycles.", + customTemplate: customTemplate({ + id: "cycle-template", + name: "Cycle Template", + steps: [ + { id: "a", name: "A", prompt: "A", teammateTemplateId: "architect", dependsOn: ["b"] }, + { id: "b", name: "B", prompt: "B", teammateTemplateId: "architect", dependsOn: ["a"] }, + ], + }), + }), + }); + assert.equal(cyclic.status, 400); + assert.equal((await cyclic.json()).error, "bad_request"); + + const unknownRunner = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + objective: "Reject unknown runner.", + customTemplate: customTemplate({ + id: "unknown-runner-template", + name: "Unknown Runner Template", + steps: [ + { id: "a", name: "A", prompt: "A", teammateTemplateId: "architect", requestedRunnerId: "missing-runner" }, + ], + }), + }), + }); + assert.equal(unknownRunner.status, 400); + assert.match((await unknownRunner.json()).message, /not registered|missing-runner/); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("custom workflow template unsafe autostart requires explicit confirmation", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-autostart-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Unsafe custom template"); + const body = { + objective: "Unsafe autostart.", + autoStart: true, + customTemplate: customTemplate({ + id: "unsafe-template", + name: "Unsafe Template", + steps: [ + { + id: "implement", + name: "Implement", + prompt: "Implement changes.", + teammateTemplateId: "engineer", + skillId: "implement", + }, + ], + }), + }; + + const rejected = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + assert.equal(rejected.status, 400); + assert.match((await rejected.json()).message, /confirmUnsafeAutoStart/); + + const accepted = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + ...body, + confirmUnsafeAutoStart: true, + }); + assert.equal(accepted.workflowSteps[0].metadata.autoStart, true); + assert.equal(accepted.workflowSteps[0].metadata.skillId, "implement"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("custom workflow template parallel branches start within compiled budget", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-parallel-")); + const runner = new ParallelDelayedRunner(); + let cleanupWorkflowId; + const daemon = await startDaemonForTest(runner); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Custom parallel template", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + allowedTools: ["read", "grep", "find", "ls"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + objective: "Run custom parallel branches.", + providerPresetId: provider.id, + autoStart: true, + customTemplate: customTemplate({ + id: "custom-parallel", + name: "Custom Parallel", + maxParallelSteps: 2, + steps: [ + { + id: "branch-a", + name: "Branch A", + prompt: "Read branch A.", + teammateTemplateId: "architect", + skillId: "plan-eng-review", + branchGroupId: "a", + }, + { + id: "branch-b", + name: "Branch B", + prompt: "Read branch B.", + teammateTemplateId: "architect", + skillId: "plan-eng-review", + branchGroupId: "b", + }, + ], + }), + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.startedSteps.length, 2); + cleanupWorkflowId = compiled.workflow.id; + await runner.waitUntilStarted(2); + assert.deepEqual(runner.runs.map((run) => run.task.title).sort(), ["Branch A", "Branch B"]); + } finally { + try { + runner.resolveAll("custom parallel cleanup"); + if (cleanupWorkflowId) { + await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "custom parallel cleanup"); + } + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("workflow orchestrator keeps serial behavior when smart scheduling max runs is one", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-serial-budget-")); + const runner = new ParallelDelayedRunner(); + let cleanupWorkflowId; + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Serial budget workflow"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + name: channel.name, + status: channel.status, + boundPath: channel.boundPath, + defaultProviderPresetId: provider.id, + defaultTeammateId: channel.defaultTeammateId, + allowedTools: ["read"], + runtimePolicy: { ...channel.runtimePolicy, queueOnConflict: false }, + }); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Serial branches", + objective: "Keep default single-start behavior.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + createParallelWorkflowStep(store, workflow.id, "Serial A", { + branchGroupId: "branch-a", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + createParallelWorkflowStep(store, workflow.id, "Serial B", { + branchGroupId: "branch-b", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.startedSteps.length, 1); + assert.equal(tick.schedulingDecision.effectiveMode, "serial"); + assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "max_concurrent_runs_one"]); + cleanupWorkflowId = workflow.id; + await runner.waitUntilStarted(1); + const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + assert.deepEqual(steps.workflowSteps.map((step) => step.status).sort(), ["queued", "running"]); + runner.resolveAll("serial source completed"); + await runner.waitUntilStarted(2); + await waitForCondition(() => store.getChannelWorkflow(workflow.id)?.status === "succeeded", "serial successor to finish without a manual tick"); + } finally { + try { + runner.resolveAll("serial cleanup"); + if (cleanupWorkflowId) { + await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "serial cleanup", { drainQueued: true }); + } + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("workflow smart scheduling keeps side-effectful branches serial", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-write-")); + const runner = new ParallelDelayedRunner(); + let cleanupWorkflowId; + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart write workflow"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + runtimePolicy: { + ...channel.runtimePolicy, + maxConcurrentRuns: 2, + }, + defaultProviderPresetId: provider.id, + }); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Write branches", + objective: "Keep write branches serial.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + createParallelWorkflowStep(store, workflow.id, "Write A", { + branchGroupId: "branch-a", + maxParallelSiblings: 2, + providerPresetId: provider.id, + requiredTools: ["write"], + }); + createParallelWorkflowStep(store, workflow.id, "Write B", { + branchGroupId: "branch-b", + maxParallelSiblings: 2, + providerPresetId: provider.id, + requiredTools: ["write"], + }); + cleanupWorkflowId = workflow.id; + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.startedSteps.length, 1); + assert.equal(tick.schedulingDecision.effectiveMode, "serial"); + assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "side_effectful_step_serial"]); + await runner.waitUntilStarted(1); + } finally { + try { + runner.resolveAll("write cleanup"); + if (cleanupWorkflowId) { + await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "write cleanup", { drainQueued: true }); + } + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("workflow smart scheduling keeps unknown-tool branches serial", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-unknown-")); + const runner = new ParallelDelayedRunner(); + let cleanupWorkflowId; + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart unknown workflow"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + runtimePolicy: { + ...channel.runtimePolicy, + maxConcurrentRuns: 2, + }, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + }); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Unknown branches", + objective: "Keep unknown tools serial.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + createParallelWorkflowStep(store, workflow.id, "Unknown A", { + branchGroupId: "branch-a", + maxParallelSiblings: 2, + providerPresetId: provider.id, + omitRequiredTools: true, + }); + createParallelWorkflowStep(store, workflow.id, "Unknown B", { + branchGroupId: "branch-b", + maxParallelSiblings: 2, + providerPresetId: provider.id, + omitRequiredTools: true, + }); + cleanupWorkflowId = workflow.id; + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.startedSteps.length, 1); + assert.equal(tick.schedulingDecision.effectiveMode, "serial"); + assert.equal(tick.schedulingDecision.safetyEvidence, "unknown"); + assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "unknown_tools_serial"]); + await runner.waitUntilStarted(1); + } finally { + try { + runner.resolveAll("unknown cleanup"); + if (cleanupWorkflowId) { + await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "unknown cleanup", { drainQueued: true }); + } + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + +test("workflow smart scheduling does not start sibling branches while approval is pending", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-approval-")); + const runner = new ParallelDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart approval workflow"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + runtimePolicy: { + ...channel.runtimePolicy, + maxConcurrentRuns: 2, + }, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + }); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Approval branches", + objective: "Wait for approval before siblings.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + createParallelWorkflowStep(store, workflow.id, "Approval A", { + branchGroupId: "branch-a", + maxParallelSiblings: 2, + providerPresetId: provider.id, + approvalGate: { mode: "standard", risk: "medium" }, + }); + createParallelWorkflowStep(store, workflow.id, "Approval B", { + branchGroupId: "branch-b", + maxParallelSiblings: 2, + providerPresetId: provider.id, + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "waiting_for_approval"); + assert.equal(tick.waitingApprovals.length, 1); + assert.equal(tick.schedulingDecision.effectiveMode, "serial"); + assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "approval_pending"]); + assert.equal(runner.runs.length, 0); + const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); + assert.equal(steps.workflowSteps.filter((step) => step.status === "waiting_for_approval").length, 1); + assert.equal(steps.workflowSteps.filter((step) => step.status === "queued").length, 1); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow dependencyMode any_succeeded can start a fan-in after mixed terminal branches", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-any-succeeded-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Any succeeded workflow", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const now = new Date().toISOString(); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Fan-in", + objective: "Start when one dependency succeeded.", + status: "ready", + createdBy: "local-user", + }, now); + const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded branch", { + branchGroupId: "branch-a", + providerPresetId: provider.id, + status: "succeeded", + }); + const failed = createParallelWorkflowStep(store, workflow.id, "Failed branch", { + branchGroupId: "branch-b", + providerPresetId: provider.id, + status: "failed", + }); + const fanIn = createParallelWorkflowStep(store, workflow.id, "Fan-in", { + branchGroupId: "join", + joinGroupId: "join-main", + providerPresetId: provider.id, + dependsOnStepIds: [succeeded.id, failed.id], + dependencyMode: "any_succeeded", + maxParallelSiblings: 2, + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.workflowStep.id, fanIn.id); + assert.equal(tick.startedSteps.length, 1); + assert.equal(runner.runs.length, 1); + assert.equal(runner.runs[0].task.title, "Fan-in"); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("fan-in requireAllBranchesTerminal is a scheduling barrier even with any_succeeded", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-barrier-")); + const runner = new ParallelDelayedRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Fan-in barrier workflow"); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + }); + const store = stores.get(cwd); + assert.ok(store); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Fan-in barrier", + objective: "Do not start synthesis before every branch is terminal.", + status: "ready", + createdBy: "local-user", + }, new Date().toISOString()); + const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded branch", { + branchGroupId: "branch-a", + providerPresetId: provider.id, + status: "succeeded", + }); + const pending = createParallelWorkflowStep(store, workflow.id, "Pending branch", { + branchGroupId: "branch-b", + providerPresetId: provider.id, + status: "queued", + autoStart: false, + }); + const fanIn = createParallelWorkflowStep(store, workflow.id, "Barrier fan-in", { + branchGroupId: "join", + joinGroupId: "join-barrier", + providerPresetId: provider.id, + position: 3, + dependsOnStepIds: [succeeded.id, pending.id], + dependencyMode: "any_succeeded", + maxParallelSiblings: 2, + fanInPolicy: { + mode: "memory_candidates", + requireAllBranchesTerminal: true, + includeFailedBranches: true, + createSummaryArtifact: false, + createMemoryCandidates: false, + memoryCandidateScope: "channel", + maxInputArtifacts: 8, + }, + }); + const independent = createParallelWorkflowStep(store, workflow.id, "Independent queued branch", { + branchGroupId: "branch-c", + providerPresetId: provider.id, + position: 4, + maxParallelSiblings: 2, + }); + + const before = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(before.status, "started"); + assert.equal(before.startedSteps.length, 1); + assert.equal(before.workflowStep.id, independent.id); + assert.equal(runner.runs.length, 1); + assert.equal(runner.runs[0].task.title, independent.title); + assert.equal(store.getChannelWorkflowStep(fanIn.id).status, "queued"); + + const cancelled = store.transitionChannelWorkflowStepStatus({ + workflowStepId: pending.id, + expectedStatus: "queued", + event: "cancel", + actor: "system", + reason: "pending branch was cancelled after its predecessor was unavailable", + now: new Date().toISOString(), + }); + assert.equal(cancelled.ok, true); + + runner.resolveAll("independent branch complete"); + await waitForCondition( + () => store.getChannelWorkflowStep(independent.id)?.status === "succeeded", + "independent branch to finish before fan-in", + ); + await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + await waitForCondition( + () => runner.runs.filter((run) => run.task.title === fanIn.title).length === 1, + "fan-in to start after every dependency becomes terminal", + ); + assert.equal(runner.runs.filter((run) => run.task.title === fanIn.title).length, 1); + } finally { + runner.resolveAll("cleanup"); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow fan-in creates summary artifact, memory candidate, selector decision, and evidence drilldown", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-evidence-")); + const runner = new RecordingRunner(); + const stores = new Map(); + const daemon = await startDaemonForTest(runner, { + storeFactory: (storeCwd) => { + const store = createSqliteRunStore(storeCwd); + stores.set(storeCwd, store); + return store; + }, + }); + try { + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Fan-in evidence workflow", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 2, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; + const store = stores.get(cwd); + assert.ok(store); + const now = new Date().toISOString(); + const workflow = store.createChannelWorkflow({ + channelId: channel.id, + title: "Fan-in evidence", + objective: "Create auditable fan-in evidence.", + status: "ready", + createdBy: "local-user", + }, now); + const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded Evidence Branch", { + branchGroupId: "branch-a", + providerPresetId: provider.id, + status: "succeeded", + }); + const failed = createParallelWorkflowStep(store, workflow.id, "Failed Evidence Branch", { + branchGroupId: "branch-b", + providerPresetId: provider.id, + status: "failed", + }); + const fanIn = createParallelWorkflowStep(store, workflow.id, "Fan-in Evidence", { + branchGroupId: "join", + joinGroupId: "join-evidence", + providerPresetId: provider.id, + dependsOnStepIds: [succeeded.id, failed.id], + dependencyMode: "any_succeeded", + maxParallelSiblings: 2, + fanInPolicy: { + mode: "memory_candidates", + requireAllBranchesTerminal: true, + includeFailedBranches: true, + createSummaryArtifact: true, + createMemoryCandidates: true, + memoryCandidateScope: "channel", + maxInputArtifacts: 8, + }, + }); + + const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(tick.status, "started"); + assert.equal(tick.workflowStep.id, fanIn.id); + const completedFanIn = await waitForWorkflowStepStatus(daemon.url, cwd, workflow.id, fanIn.id, "succeeded"); + assert.ok(completedFanIn.metadata.selectorDecisionIds.length > 0); + assert.ok(completedFanIn.metadata.outputArtifactId); + assert.ok(completedFanIn.metadata.fanInSummaryArtifactId); + assert.equal(completedFanIn.metadata.memoryCandidateIds.length, 1); + + const outputPacket = await getArtifactJson(daemon.url, cwd, completedFanIn.metadata.outputArtifactId); + assert.equal(outputPacket.sourceRefs.some((ref) => ref.type === "workflow_selector_decision" && ref.id === completedFanIn.metadata.selectorDecisionIds[0]), true); + + const workflowEvidence = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); + assert.equal(workflowEvidence.selectorDecisions.some((decision) => decision.id === completedFanIn.metadata.selectorDecisionIds[0] && decision.status === "accepted"), true); + assert.equal(workflowEvidence.memoryCandidates.some((candidate) => + candidate.memory.id === completedFanIn.metadata.memoryCandidateIds[0] && + candidate.memory.reviewState === "candidate" && + candidate.memory.verifyBeforeUse === true && + candidate.sourceWorkflowStepIds.includes(completedFanIn.id) + ), true); + + const stepEvidence = await getJson(`${daemon.url}/workflow-steps/${encodeURIComponent(completedFanIn.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); + assert.equal(stepEvidence.workflowStep.step.id, completedFanIn.id); + assert.equal(stepEvidence.selectorDecisions.length, 1); + assert.equal(stepEvidence.memoryCandidates.length, 1); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow cancel aborts active run, queued steps, and pending workflow approvals", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancel-")); + const runner = new DelayedRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Cancel workflow"); + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Cancel the workflow.", + providerPresetId: provider.id, + createdBy: "local-user", + }); + const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(firstTick.status, "started"); + await waitForCondition(() => runner.runs.length === 1, "workflow runner start before cancel"); + + const cancelled = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/cancel?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(cancelled.workflow.status, "cancelled"); + assert.equal(cancelled.cancelledRunIds.length, 1); + assert.deepEqual(cancelled.workflowSteps.map((step) => step.status), Array(8).fill("cancelled")); + + const run = await getJson(`${daemon.url}/runs/${encodeURIComponent(cancelled.cancelledRunIds[0])}?cwd=${encodeURIComponent(cwd)}`); + assert.equal(run.status, "cancelled"); + } finally { + runner.resolve("cleanup"); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("workflow step retry starts a blocked step after configuration is repaired", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-retry-")); + const runner = new RecordingRunner(); + const daemon = await startDaemonForTest(runner); + try { + const channel = await createWorkflowTestChannel(daemon.url, cwd, "Retry workflow"); + simulateLegacyChannelWithoutModel(cwd, channel.id); + const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { + templateId: "autoplan-lite", + objective: "Retry a blocked workflow step.", + createdBy: "local-user", + }); + const blocked = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(blocked.status, "blocked"); + assert.equal(blocked.workflowStep.metadata.templateStepId, "product-discovery"); + assert.match(blocked.blocker, /provider preset/i); + + const provider = (await createStaticChatProvider(daemon.url, cwd, { + capabilities: ["chat", "reasoning", "tool_call"], + })).provider; + await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { + defaultProviderPresetId: provider.id, + }); + + const retried = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(blocked.workflowStep.id)}/retry?cwd=${encodeURIComponent(cwd)}`, {}); + assert.equal(retried.status, "started"); + assert.equal(retried.workflowStep.metadata.templateStepId, "product-discovery"); + await waitForCondition(() => runner.runs.length >= 1, "workflow retry runner start"); + assert.equal(runner.runs[0].task.title, "Product Discovery"); + const roster = await getJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}&includeHidden=true&includeDisabled=true`); + const assignedTeammate = roster.teammates.find((teammate) => teammate.id === retried.workflowStep.toParticipantId); + assert.ok(assignedTeammate); + assert.equal(runner.runs[0].systemPrompt, assignedTeammate.systemPrompt); + assert.deepEqual( + runner.runs[0].appendSystemPrompt.slice(0, assignedTeammate.appendSystemPrompt.length), + assignedTeammate.appendSystemPrompt, + ); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("channel link preview blocks localhost targets instead of fetching them", async () => { + const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-links-")); + const daemon = await startDaemonForTest(); + try { + const channel = await createChannel(daemon.url, cwd); + const preview = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { + url: "http://localhost:12345/private", + }); + assert.equal(preview.linkPreview.status, "blocked"); + assert.equal(preview.linkPreview.errorMessage, "Public HTTP blocks local or private network targets."); + } finally { + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}); diff --git a/packages/daemon/test/channel-workspace.test.mjs b/packages/daemon/test/channel-workspace.test.mjs index 2be27cd05..643d67ad9 100644 --- a/packages/daemon/test/channel-workspace.test.mjs +++ b/packages/daemon/test/channel-workspace.test.mjs @@ -1,11655 +1,36 @@ -import { appendChannelItemFixture, getChannelItemViewFixture, listChannelItemViewsFixture, updateChannelItemFixture } from "../../core/test/channel-conversation-fixture.mjs"; import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { readFileSync, realpathSync } from "node:fs"; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; import test from "node:test"; -import { Document, Packer, Paragraph } from "docx"; -import { - DEFAULT_WORKSPACE_ID, - createCompositeSecretResolver, - createSqliteRunStore, - projectConversationItemToChannelMessage, - resolveStoragePaths, -} from "../../core/dist/index.js"; -import { createRequiredParticipantManifestV1, createWorkflowParticipantAuthorityV1 } from "../../contracts/dist/index.js"; -import { PiRunnerAdapter } from "../../runner-pi/dist/index.js"; -import { createDaemonServer } from "../dist/index.js"; -import { buildContextPack, estimateTextTokens, resolveProviderContextBudget } from "../dist/features/channels/context-packs.js"; -import { routeChannelMessageReply } from "../dist/features/channels/channel-message-router.js"; -import { resolveChannelReplyExecutionTarget } from "../dist/features/channels/channel-reply-execution-target.js"; -import { resolveWorkflowParticipantRunProfile } from "../dist/features/channels/workflow-participant-run-profile.js"; -import { preflightWorkflowParticipantProvider } from "../dist/features/channels/workflow-participant-provider.js"; -import { prepareWorkflowStepStart } from "../dist/features/channels/workflows.js"; -import { planConversationContextSummary } from "../dist/features/channels/context-processing/conversation-summary-coordinator.js"; -import { buildLingAgentSessionViewForChannel } from "../dist/features/channels/ling-agent-session-view.js"; -import { buildLingAgentTurnEnvelope } from "../dist/features/channels/ling-agent-turn-envelope.js"; -import { assertChannelWorkspaceReady } from "../dist/features/channels/workspace.js"; -import { findActiveChannelReplyRun } from "../dist/features/channels/channel-reply-dispatch.js"; -import { availabilityIncidentInputForProviderRuntimeFailure } from "../dist/features/channels/provider-runtime-observations.js"; -import { selectChannelWorkflowIntent } from "../dist/features/channels/channel-intent-selector.js"; -import { confirmChannelExecutionProposalWorkflow } from "../dist/features/channels/proposal-workflows.js"; -import { linkBodyExtractorV1 } from "../dist/features/channels/materialization/extractors/link-body.js"; -import { officeExtractorV1 } from "../dist/features/channels/materialization/extractors/office.js"; -import { pdfExtractorV1 } from "../dist/features/channels/materialization/extractors/pdf.js"; -import { attachmentMaterializationFingerprint, linkPreviewMaterializationFingerprint } from "../dist/features/channels/materialization/fingerprints.js"; -import { materializeInlineChannelInputs } from "../dist/features/channels/materialization/inline.js"; -import { OfficeArtifactService } from "../dist/features/office-documents/artifacts.js"; -import { materializeInlineTeammateDirectInputs } from "../dist/features/teammates/direct-materialization.js"; -import { ensureDirectWorkspaceReady } from "../dist/features/teammates/direct-workspace.js"; -import { createInitialWorkspaceBinding, createManagedGitWorktree, resolveGitRepoRoot } from "../dist/features/workspaces/managed-worktrees.js"; -import { prepareChannelCreateBody } from "./conversation-model-test-fixture.mjs"; -import { ensureEmptyGitRepo, ensureGitRepo, ensureGitRepoForChannelPost } from "./git-test-helpers.mjs"; -import { commandOperationCandidate, fileOperationCandidate } from "./support/channel-operation-candidate-fixture.mjs"; -import { messageText, startRealPiChannelFixture, systemText } from "./support/channel-operation-real-pi-fixture.mjs"; -import { prepareFixtureTaskInput } from "./support/prepared-input-fixture.mjs"; -import { ToolFreePiRunner } from "./support/tool-free-pi-runner.mjs"; -import { startMockOpenAICompatibleServer } from "./support/mock-openai-compatible-server.mjs"; - -const require = createRequire(import.meta.url); -const ASYNC_SETTLE_TIMEOUT_MS = 10_000; -const TEST_RESTRICTED_NODE_PATH = realpathSync(process.execPath); -const TEST_RESTRICTED_NODE_SHA256 = createHash("sha256") - .update(readFileSync(TEST_RESTRICTED_NODE_PATH)) - .digest("hex"); - -function withRestrictedCommandReadiness(config) { - return { - ...config, - testVerifiedPiRunner: true, - sandbox: { - ...config.sandbox, - restrictedCommands: { - // These orchestration tests never execute this helper. They need a - // Host-owned runtime identity so the fail-closed readiness gate can - // admit the typed plan before their synthetic Runner emits evidence. - inputHelper: { - path: TEST_RESTRICTED_NODE_PATH, - sha256: TEST_RESTRICTED_NODE_SHA256, - version: "channel-workspace-readiness-v1", - }, - executables: { - node: { - path: TEST_RESTRICTED_NODE_PATH, - sha256: TEST_RESTRICTED_NODE_SHA256, - }, - }, - }, - }, - }; -} - -class VerifiedExecutionTestRunner extends PiRunnerAdapter { - constructor(delegate) { - super(); - this.delegate = delegate; - } - - async prepareTaskInput(options) { - return this.delegate.prepareTaskInput?.(options) ?? prepareFixtureTaskInput(options); - } - - async runTask(options) { - return this.delegate.runTask(options); - } -} - -function projectionFor(item, assignment) { - return { - projectionVersion: `projection:${item.id}:${assignment.revision}`, - sourceStateFingerprint: `source-state:${item.id}:${assignment.revision}`, - threadId: item.threadId, - turnId: item.turnId, - segmentId: assignment.segmentId, - throughItemSequence: item.sequence, - summaryIds: [], - sourceRefs: [ - { type: "thread", id: item.threadId }, - { type: "turn", id: item.turnId }, - { type: "conversation_item", id: item.id }, - ], - policyVersion: "test-projector-v1", - visibilityFingerprint: "channel_shared", - }; -} - -async function readPdfFixtureBytes() { - const content = "BT\n/F1 24 Tf\n100 700 Td\n(Ling PDF fixture) Tj\nET"; - const objects = [ - "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /MediaBox [0 0 612 792] /Contents 5 0 R >>", - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", - `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`, - ]; - let pdf = "%PDF-1.4\n"; - const offsets = [0]; - for (const [index, object] of objects.entries()) { - offsets.push(Buffer.byteLength(pdf, "latin1")); - pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; - } - const xrefOffset = Buffer.byteLength(pdf, "latin1"); - pdf += `xref\n0 ${objects.length + 1}\n`; - pdf += "0000000000 65535 f \n"; - pdf += offsets - .slice(1) - .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) - .join(""); - pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; - return Buffer.from(pdf, "latin1"); -} - -async function readDocxFixtureBytes() { - const document = new Document({ - sections: [{ children: [new Paragraph("Walking on imported air")] }], - }); - return Buffer.from(await Packer.toBuffer(document)); -} - -test("health exposes channel auto_route capability", async () => { - const disabledDaemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: false, intentSelector: false, teamObserverBus: false } }); - try { - const health = await getJson(`${disabledDaemon.url}/health`); - assert.equal(health.features.channels.autoRoute, false); - assert.equal(health.features.channels.intentSelector, false); - assert.equal(health.features.channels.teamObserverBus, false); - } finally { - await disabledDaemon.close(); - } - - const enabledDaemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: true, intentSelector: true, teamObserverBus: true } }); - try { - const health = await getJson(`${enabledDaemon.url}/health`); - assert.equal(health.features.channels.autoRoute, true); - assert.equal(health.features.channels.intentSelector, true); - assert.equal(health.features.channels.teamObserverBus, true); - } finally { - await enabledDaemon.close(); - } -}); - -test("channel runtime policy rejects removed executionMode field", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-removed-execution-mode-")); - const daemon = await startDaemonForTest(); - try { - const response = await fetch(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "Old runtime policy", - status: "active", - boundPath: cwd, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 2, - executionMode: "parallel", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - }), - }); - assert.equal(response.status, 400); - const body = await response.json(); - assert.match(body.message, /executionMode has been removed/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("provider runtime availability mapper records transient failures only", () => { - const providerPreset = { - id: "provider_static", - kind: "glm", - displayName: "Static Chat", - providerId: "static-chat", - model: "static-model", - api: "google-generative-ai", - }; - - const quota = availabilityIncidentInputForProviderRuntimeFailure({ - providerPreset, - code: "rate_limited", - message: "Too many requests", - now: "2026-06-27T00:00:00.000Z", - }); - assert.equal(quota?.kind, "quota"); - assert.equal(quota?.state, "rate_limited"); - assert.equal(quota?.providerPresetId, "provider_static"); - assert.equal(quota?.modelSignature?.model, "static-model"); - assert.equal(quota?.expiresAt, "2026-06-27T00:20:00.000Z"); - - const network = availabilityIncidentInputForProviderRuntimeFailure({ - providerPreset, - error: Object.assign(new Error("fetch failed: EAI_AGAIN"), { code: "network_failed" }), - now: "2026-06-27T00:00:00.000Z", - }); - assert.equal(network?.kind, "network"); - assert.equal(network?.state, "offline"); - - const promptLeak = availabilityIncidentInputForProviderRuntimeFailure({ - providerPreset, - error: new Error("Runner echoed internal channel context instead of an assistant reply."), - now: "2026-06-27T00:00:00.000Z", - }); - assert.equal(promptLeak, undefined); -}); - -test("channel proposal confirmation claim is durable, first-writer-wins, and recoverable", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-cas-")); - const store = createSqliteRunStore(cwd); - const now = "2026-09-19T10:00:00.000Z"; - try { - const { channel, message } = createStoreChannelAndMessage(store, cwd, "prepare a safe change"); - const proposal = store.createChannelExecutionProposal({ - channelId: channel.id, - sourceMessageId: message.id, - proposedByTeammateId: "ling_agent", - title: "Safe change", - prompt: "Prepare a safe change", - assigneeTeammateId: "ling_agent", - status: "draft", - }, now); - assert.equal(proposal.revision, 1); - - const first = store.claimChannelExecutionProposal( - proposal.id, - "proposal-confirm:first", - now, - "2026-09-19T10:05:00.000Z", - ); - assert.equal(first.kind, "claimed"); - assert.equal(first.proposal.revision, 2); - - const second = store.claimChannelExecutionProposal( - proposal.id, - "proposal-confirm:second", - now, - "2026-09-19T10:05:00.000Z", - ); - assert.equal(second.kind, "busy"); - - assert.equal(store.updateChannelExecutionProposal(proposal.id, { - status: "cancelled", - }, now), undefined, "a stale UI cancel cannot steal an active confirmation claim"); - assert.equal(store.updateChannelExecutionProposal(proposal.id, { - status: "blocked", - expectedRevision: 1, - }, now), undefined, "an old revision cannot overwrite a claimed proposal"); - - const blocked = store.updateChannelExecutionProposal(proposal.id, { - status: "blocked", - errorMessage: "provider unavailable", - expectedRevision: first.proposal.revision, - confirmationClaimId: "proposal-confirm:first", - }, now); - assert.equal(blocked?.status, "blocked"); - assert.equal(blocked?.revision, 3); - - const recovered = store.claimChannelExecutionProposal( - proposal.id, - "proposal-confirm:recovery", - "2026-09-19T10:06:00.000Z", - "2026-09-19T10:11:00.000Z", - ); - assert.equal(recovered.kind, "claimed", "blocked proposals can be retried after the prior claim is released"); - assert.equal(recovered.proposal.revision, 4); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("an expired proposal confirmation lease can be reclaimed without releasing the old claim", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proposal-lease-expiry-")); - const store = createSqliteRunStore(cwd); - try { - const now = "2026-09-19T11:00:00.000Z"; - const { channel, message } = createStoreChannelAndMessage(store, cwd, "recover an abandoned confirmation"); - const proposal = store.createChannelExecutionProposal({ - channelId: channel.id, - sourceMessageId: message.id, - proposedByTeammateId: "ling_agent", - title: "Abandoned confirmation", - prompt: "Recover an abandoned confirmation", - assigneeTeammateId: "ling_agent", - status: "draft", - }, now); - - const first = store.claimChannelExecutionProposal( - proposal.id, - "proposal-confirm:abandoned", - now, - "2026-09-19T11:05:00.000Z", - ); - assert.equal(first.kind, "claimed"); - assert.equal(first.proposal.revision, 2); - - const recovered = store.claimChannelExecutionProposal( - proposal.id, - "proposal-confirm:recovered", - "2026-09-19T11:06:00.000Z", - "2026-09-19T11:11:00.000Z", - ); - assert.equal(recovered.kind, "claimed"); - assert.equal(recovered.proposal.revision, 3); - - assert.equal(store.updateChannelExecutionProposal(proposal.id, { - status: "blocked", - errorMessage: "The expired owner cannot finalize this proposal.", - expectedRevision: first.proposal.revision, - confirmationClaimId: "proposal-confirm:abandoned", - }, "2026-09-19T11:06:00.000Z"), undefined); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel branches follow archive restore and delete lifecycle", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-branches-")); - const stores = []; - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.push(store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const store = stores.find((item) => item.paths.cwd === cwd); - assert.ok(store); - const branch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { - name: "Model routing fix", - })).branch; - assert.ok(branch.rootThreadId); - const branchThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; - assert.equal(branchThread.kind, "channel_discussion"); - assert.equal(branchThread.channelId, channel.id); - assert.equal(branchThread.title, "Model routing fix"); - - const secondBranch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { - name: "Prompt cleanup", - })).branch; - assert.ok(secondBranch.rootThreadId); - assert.notEqual(secondBranch.rootThreadId, branch.rootThreadId); - appendChannelItemFixture(store, { - channelId: channel.id, - threadId: branch.rootThreadId, - authorType: "local-user", - body: "Message in model routing branch", - }, "2026-06-22T00:01:00.000Z"); - appendChannelItemFixture(store, { - channelId: channel.id, - threadId: secondBranch.rootThreadId, - authorType: "local-user", - body: "Message in prompt cleanup branch", - }, "2026-06-22T00:02:00.000Z"); - const firstBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(branch.rootThreadId)}`); - assert.deepEqual( - firstBranchMessages.messages - .filter((message) => message.authorType === "local-user") - .map((message) => message.body), - ["Message in model routing branch"], - ); - const secondBranchMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}&threadId=${encodeURIComponent(secondBranch.rootThreadId)}`); - assert.deepEqual( - secondBranchMessages.messages - .filter((message) => message.authorType === "local-user") - .map((message) => message.body), - ["Message in prompt cleanup branch"], - ); - - const legacyBranch = store.createChannelBranch({ - channelId: channel.id, - name: "Legacy branch", - }, "2026-06-22T00:00:00.000Z"); - assert.equal(legacyBranch.rootThreadId, undefined); - const repairedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`); - const repairedLegacy = repairedBranches.branches.find((item) => item.id === legacyBranch.id); - assert.equal(repairedLegacy?.rootThreadId, undefined); - assert.equal(store.getChannelBranch(legacyBranch.id).rootThreadId, undefined); - const explicitRepair = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(legacyBranch.id)}/repair?cwd=${encodeURIComponent(cwd)}`, {}); - assert.ok(explicitRepair.branch.rootThreadId); - assert.equal(store.getChannelBranch(legacyBranch.id).rootThreadId, explicitRepair.branch.rootThreadId); - - let listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listed.channels.some((item) => item.id === channel.id), true); - assert.equal(listed.branches.some((item) => item.id === branch.id), true); - - const archivedBranch = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "archived", - })).branch; - assert.equal(archivedBranch.status, "archived"); - assert.ok(archivedBranch.archivedAt); - const archivedThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; - assert.ok(archivedThread.archivedAt); - assert.equal(archivedThread.archivedReason, "channel_branch_archived"); - - listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listed.branches.some((item) => item.id === branch.id), false); - - let archivedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}&archived=true`); - assert.equal(archivedBranches.branches.some((item) => item.id === branch.id), true); - - const restoredBranch = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "active", - })).branch; - assert.equal(restoredBranch.status, "active"); - assert.equal(restoredBranch.archivedAt, undefined); - const restoredThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; - assert.equal(restoredThread.archivedAt, undefined); - assert.equal(restoredThread.archivedReason, undefined); - - const activeDelete = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "DELETE", - }); - assert.equal(activeDelete.status, 409); - - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "archived", - }); - await deleteJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches/${encodeURIComponent(branch.id)}?cwd=${encodeURIComponent(cwd)}`); - const deletedBranchThread = (await getJson(`${daemon.url}/threads/${encodeURIComponent(branch.rootThreadId)}?cwd=${encodeURIComponent(cwd)}`)).thread; - assert.equal(deletedBranchThread.id, branch.rootThreadId); - assert.ok(deletedBranchThread.archivedAt); - archivedBranches = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}&archived=true`); - assert.deepEqual(archivedBranches.branches, []); - - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "archived", - }); - listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listed.channels.some((item) => item.id === channel.id), false); - - const archivedChannels = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}&archived=true`); - assert.deepEqual(archivedChannels.channels.map((item) => item.id), [channel.id]); - - const restoredChannel = (await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "active", - })).channel; - assert.equal(restoredChannel.status, "active"); - listed = await getJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listed.channels.some((item) => item.id === channel.id), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("context budget manager resolves provider modes and trims lower priority sections", () => { - const providerBase = { - id: "provider_context_test", - kind: "openai", - displayName: "Context Test", - providerId: "openai", - model: "context-model", - api: "openai-responses", - headers: {}, - input: ["text"], - compat: {}, - capabilities: ["chat"], - enabled: true, - createdAt: "2026-06-10T00:00:00.000Z", - updatedAt: "2026-06-10T00:00:00.000Z", - }; - - const known = resolveProviderContextBudget({ ...providerBase, contextWindow: 12000, maxTokens: 2000 }, "channel_reply"); - assert.equal(known.mode, "model_known"); - assert.equal(known.contextWindowTokens, 12000); - assert.equal(known.reservedOutputTokens, 2000); - assert.equal(known.inputBudgetTokens, 9488); - assert.equal(known.capabilitySource, "user_override"); - assert.equal(known.capabilityConfidence, "known"); - - const estimated = resolveProviderContextBudget(providerBase, "intent_selector"); - assert.equal(estimated.mode, "model_estimated"); - assert.equal(estimated.contextWindowTokens, 32768); - assert.equal(estimated.reservedOutputTokens, 512); - assert.equal(estimated.capabilitySource, "provider_metadata"); - assert.equal(estimated.capabilityConfidence, "estimated"); - - const unknown = resolveProviderContextBudget({ ...providerBase, kind: "openai-compatible" }, "channel_reply"); - assert.equal(unknown.mode, "model_unknown"); - assert.equal(unknown.contextWindowTokens, 8192); - assert.equal(unknown.capabilitySource, "conservative_default"); - assert.equal(unknown.capabilityConfidence, "conservative"); - - const trimmed = buildContextPack({ - kind: "channel_reply", - providerPreset: { ...providerBase, contextWindow: 1800, maxTokens: 512 }, - sections: [ - { id: "must", role: "user", priority: "must_keep", text: "must", estimatedTokens: 900 }, - { id: "high", role: "evidence", priority: "high", text: "high", estimatedTokens: 900 }, - { id: "low", role: "evidence", priority: "low", text: "low", estimatedTokens: 900 }, - ], - }); - assert.deepEqual(trimmed.sections.map((section) => section.id), ["must"]); - assert.deepEqual(trimmed.droppedSectionIds, ["low", "high"]); - assert.equal(trimmed.overBudget, true); - - const protectedOnly = buildContextPack({ - kind: "channel_reply", - providerPreset: { ...providerBase, contextWindow: 1800, maxTokens: 512 }, - sections: [{ id: "current_user_turn", role: "user", priority: "must_keep", text: "full current request", estimatedTokens: 3_000 }], - }); - assert.deepEqual(protectedOnly.sections.map((section) => section.id), ["current_user_turn"]); - assert.deepEqual(protectedOnly.droppedSectionIds, []); - assert.equal(protectedOnly.overBudget, true); -}); - -test("ling agent session view is open when channel has no active work", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-open-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "hello"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.id, `ling-agent-session:${channel.id}`); - assert.equal(view.status, "open"); - assert.equal(view.owner, "ling_agent"); - assert.equal(view.confirmation, undefined); - assert.equal(view.progress, undefined); - assert.equal(view.finalResult, undefined); - assert.deepEqual(view.pendingApprovalIds, []); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view projects draft proposal as task confirmation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-proposal-")); - const store = createSqliteRunStore(cwd); - try { - const { channel, message } = createStoreChannelAndMessage(store, cwd, `在 ${cwd} 下创建 README.md`); - const proposal = store.createChannelExecutionProposal({ - channelId: channel.id, - sourceMessageId: message.id, - proposedByTeammateId: "ling_agent", - title: "Create README.md", - prompt: `Target: ${cwd}/README.md`, - assigneeTeammateId: "ling_agent", - requiredTools: ["write"], - riskSummary: "Need write permission for README.md.", - status: "draft", - }, "2026-06-11T00:01:00.000Z"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "ready_for_confirmation"); - assert.equal(view.objective, proposal.title); - assert.equal(view.confirmation?.proposalId, proposal.id); - assert.deepEqual(view.confirmation?.requiredTools, ["write"]); - assert.equal(view.confirmation?.assigneeTeammateId, "ling_agent"); - assert.ok(view.evidenceRefs.some((ref) => ref.type === "proposal" && ref.id === proposal.id)); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("proposed plan APIs hydrate deleted source message state without deleting plans", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-proposed-plan-source-state-")); - const store = createSqliteRunStore(cwd); - let thread; - let channel; - let message; - try { - ({ channel, message } = createStoreChannelAndMessage(store, cwd, "source plan")); - thread = store.createLingThread({ - kind: "channel", - channelId: channel.id, - title: "Plan source state", - objective: "Hydrate proposed plan source state.", - }, "2026-06-11T00:01:00.000Z"); - store.close(); - - const daemon = await startDaemonForTest(); - try { - const available = await postJson(`${daemon.url}/threads/${encodeURIComponent(thread.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}`, { - id: "plan_available_source", - channelId: channel.id, - sourceMessageId: message.id, - title: "Available source plan", - bodyMarkdown: "Use the source message.", - }); - assert.equal(available.plan.sourceMessageState, "available"); - - const orphan = await postJson(`${daemon.url}/threads/${encodeURIComponent(thread.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}`, { - id: "plan_deleted_source", - channelId: channel.id, - sourceMessageId: "msg_deleted_source", - title: "Deleted source plan", - bodyMarkdown: "Keep this plan for audit history.", - }); - assert.equal(orphan.plan.sourceMessageState, "deleted"); - - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposed-plans?cwd=${encodeURIComponent(cwd)}&limit=20`); - const listedOrphan = listed.plans.find((plan) => plan.id === "plan_deleted_source"); - assert.equal(listedOrphan?.sourceMessageState, "deleted"); - assert.equal(listed.plans.some((plan) => plan.id === "plan_available_source" && plan.sourceMessageState === "available"), true); - } finally { - await daemon.close(); - } - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel team metrics exposes visible evidence total separately from team evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-evidence-summary-")); - const store = createSqliteRunStore(cwd); - try { - const { channel, message } = createStoreChannelAndMessage(store, cwd, "collect evidence"); - const webMessage = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "ai-teammate", - authorId: "ling_agent", - body: "web evidence", - fileRefs: [{ path: `${cwd}/README.md`, line: 12 }], - }, "2026-06-11T00:02:00.000Z"); - const workUnit = store.createWorkUnit({ - id: "wu_channel_web_evidence", - workspaceId: DEFAULT_WORKSPACE_ID, - kind: "channel_ai_reply", - ownerType: "channel_message", - ownerId: webMessage.id, - channelId: channel.id, - idempotencyKey: "wu-channel-web-evidence", - createdByActor: "system", - request: { messageId: webMessage.id }, - linkedMessageId: webMessage.id, - now: "2026-06-11T00:02:05.000Z", - }); - store.appendRuntimeEvent({ - workUnitId: workUnit.id, - type: "web_evidence_recorded", - actor: "system", - ts: "2026-06-11T00:02:10.000Z", - data: { - toolSurface: "web", - sourceMessageId: message.id, - linkedMessageId: webMessage.id, - sourceCount: 1, - readUrlCount: 1, - status: "degraded", - query: "ling stack", - sources: [{ url: "https://example.com/source", title: "Example source" }], - readUrls: [{ url: "https://example.com/read", status: "blocked", reason: "timeout" }], - updatedAt: "2026-06-11T00:02:10.000Z", - }, - }); - const task = store.createTask({ - channelId: channel.id, - title: "Evidence task", - prompt: "Collect visible evidence.", - }, "2026-06-11T00:03:00.000Z"); - const run = store.createRun({ - id: "run_channel_visible_evidence", - cwd, - taskId: task.id, - channelId: channel.id, - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: task.id, title: task.title }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-11T00:04:00.000Z", - }); - store.createApprovalRequest({ - runId: run.id, - taskId: task.id, - channelId: channel.id, - actionType: "bash", - risk: "medium", - title: "Approve command", - description: "Approve visible evidence command.", - payload: { type: "bash", cwd, command: "echo ok", riskFlags: [] }, - }, "2026-06-11T00:04:30.000Z"); - store.createArtifact({ - runId: run.id, - type: "file", - path: `${cwd}/report.md`, - title: "report.md", - createdAt: "2026-06-11T00:05:00.000Z", - }); - store.createChannelAttachment({ - channelId: channel.id, - messageId: webMessage.id, - kind: "text", - fileName: "notes.txt", - contentType: "text/plain", - sizeBytes: 20, - path: `${cwd}/notes.txt`, - createdAt: "2026-06-11T00:05:30.000Z", - }); - for (let index = 0; index < 25; index += 1) { - store.createChannelAttachment({ - channelId: channel.id, - messageId: webMessage.id, - kind: "text", - fileName: `extra-${index}.txt`, - contentType: "text/plain", - sizeBytes: 20 + index, - path: `${cwd}/extra-${index}.txt`, - createdAt: `2026-06-11T00:05:${String(31 + index).padStart(2, "0")}.000Z`, - }); - } - store.createChannelTeamEvidenceItem({ - channelId: channel.id, - turnId: webMessage.id, - agentId: "engineer", - kind: "specialist_note", - evidenceGrade: "observed", - summary: "Team-only specialist evidence.", - visibility: "internal", - status: "completed", - }, "2026-06-11T00:06:00.000Z"); - store.close(); - - const daemon = await startDaemonForTest(); - try { - const response = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/team-metrics?cwd=${encodeURIComponent(cwd)}&limit=50`); - assert.equal(response.metrics.summary.totalEvidenceCount, 1); - assert.equal(response.metrics.visibleEvidence.totalCount, 24); - assert.deepEqual(response.metrics.visibleEvidence.countsByKind, { - runs: 1, - approvals: 1, - browser: 2, - files: 20, - team_evidence: 1, - }); - } finally { - await daemon.close(); - } - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("approval-only workflow owner closes immediately after approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-approval-owner-")); - const store = createSqliteRunStore(cwd); - let workflow; - let step; - let approval; - try { - const channel = store.createChannel({ name: "Approval owner", boundPath: cwd }, "2026-06-11T00:00:00.000Z"); - workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Review risky command", - objective: "Close the approval-only workflow owner.", - status: "waiting_for_approval", - createdBy: "system", - }, "2026-06-11T00:01:00.000Z"); - step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - title: "Review risky shell command", - prompt: "Review the proposed command.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - status: "waiting_for_approval", - }, "2026-06-11T00:01:10.000Z"); - approval = store.createApprovalRequest({ - workflowStepId: step.id, - channelId: channel.id, - actionType: "bash", - risk: "high", - title: "Review risky shell command", - description: "Approval-only legacy workflow step.", - payload: { type: "bash", cwd, command: "git status --short", riskFlags: ["requires_user_review"] }, - }, "2026-06-11T00:01:20.000Z"); - store.close(); - - const daemon = await startDaemonForTest(); - try { - const resolved = await postJson(`${daemon.url}/approvals/${encodeURIComponent(approval.id)}/approve?cwd=${encodeURIComponent(cwd)}`, { - reason: "Reviewed.", - }); - assert.equal(resolved.approval.status, "approved"); - - const workflowResponse = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflowResponse.workflow.status, "succeeded"); - const stepsResponse = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - assert.equal(stepsResponse.workflowSteps.find((candidate) => candidate.id === step.id)?.status, "succeeded"); - } finally { - await daemon.close(); - } - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view projects active workflow approval wait", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-approval-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "帮我跑测试"); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Run tests", - objective: "Run the test suite.", - status: "running", - createdBy: "ling_agent", - }, "2026-06-11T00:01:00.000Z"); - const step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - title: "Execute test command", - prompt: "Run tests.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - status: "waiting_for_approval", - }, "2026-06-11T00:02:00.000Z"); - const run = store.createRun({ - id: "run_ling_agent_session_waiting", - cwd, - taskId: "task_ling_agent_session_waiting", - channelId: channel.id, - teammateId: "ling_agent", - workflowStepId: step.id, - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_ling_agent_session_waiting", title: "Execute test command" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-11T00:03:00.000Z", - }); - store.markRunWaitingForApproval(run.id, "2026-06-11T00:04:00.000Z"); - store.updateChannelWorkflowStep(step.id, { runId: run.id }, "2026-06-11T00:04:00.000Z"); - const approval = store.createApprovalRequest({ - runId: run.id, - taskId: run.taskId, - channelId: channel.id, - actionType: "bash", - risk: "medium", - title: "Approve test command", - description: "Run tests.", - payload: { type: "bash", cwd, command: "pnpm test", riskFlags: [] }, - expiresAt: "2999-01-01T00:00:00.000Z", - }, "2026-06-11T00:05:00.000Z"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "waiting_for_approval"); - assert.equal(view.activeWorkflowId, workflow.id); - assert.equal(view.activeRunId, run.id); - assert.deepEqual(view.pendingApprovalIds, [approval.id]); - assert.equal(view.progress?.workflowId, workflow.id); - assert.equal(view.progress?.activeStepId, step.id); - assert.ok(view.evidenceRefs.some((ref) => ref.type === "approval" && ref.id === approval.id)); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view projects terminal workflow final result", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-final-workflow-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "实现并验证"); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Implement feature", - objective: "Implement and verify the feature.", - status: "succeeded", - createdBy: "ling_agent", - }, "2026-06-11T00:01:00.000Z"); - const firstStep = store.createChannelWorkflowStep({ - workflowId: workflow.id, - position: 1, - title: "Implementation", - prompt: "Implement.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - status: "succeeded", - }, "2026-06-11T00:02:00.000Z"); - const secondStep = store.createChannelWorkflowStep({ - workflowId: workflow.id, - position: 2, - title: "Verification", - prompt: "Verify.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - status: "succeeded", - }, "2026-06-11T00:03:00.000Z"); - const verificationRun = store.createRun({ - id: "run_ling_agent_session_final_verification", - cwd, - taskId: "task_ling_agent_session_final_verification", - channelId: channel.id, - teammateId: "ling_agent", - workflowStepId: secondStep.id, - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_ling_agent_session_final_verification", title: "Verify feature" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-11T00:03:30.000Z", - }); - store.markRunSucceeded(verificationRun.id, "2026-06-11T00:04:30.000Z"); - const artifact = store.createArtifact({ - runId: verificationRun.id, - type: "file", - path: `${cwd}/src/feature.ts`, - title: "feature.ts", - contentType: "text/typescript; charset=utf-8", - sizeBytes: 128, - createdAt: "2026-06-11T00:04:45.000Z", - }); - store.updateChannelWorkflowStep(firstStep.id, { resultSummary: "Implemented feature." }, "2026-06-11T00:04:00.000Z"); - store.updateChannelWorkflowStep(secondStep.id, { - runId: verificationRun.id, - resultSummary: "Verified checks.", - metadata: { outputArtifactIds: [artifact.id] }, - }, "2026-06-11T00:05:00.000Z"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "succeeded"); - assert.equal(view.finalResult?.outcome, "succeeded"); - assert.equal(view.finalResult?.summary, "Verified checks."); - assert.deepEqual(view.finalResult?.changedPaths, [`${cwd}/src/feature.ts`]); - assert.deepEqual(view.finalResult?.artifactIds, [artifact.id]); - assert.deepEqual(view.finalResult?.checks.map((check) => check.title), ["Implementation", "Verification"]); - assert.ok(view.evidenceRefs.some((ref) => ref.type === "workflow" && ref.id === workflow.id)); - assert.ok(view.evidenceRefs.some((ref) => ref.type === "run" && ref.id === verificationRun.id)); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view projects blocked workflow final result with next action", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-blocked-workflow-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "需要写文件"); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Create README.md", - objective: "Create the requested file.", - status: "blocked", - createdBy: "ling_agent", - }, "2026-06-11T00:01:00.000Z"); - const step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - position: 1, - title: "Create file", - prompt: "Create README.md.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - status: "blocked", - }, "2026-06-11T00:02:00.000Z"); - store.updateChannelWorkflowStep(step.id, { blocker: "Write permission was not granted." }, "2026-06-11T00:03:00.000Z"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "blocked"); - assert.equal(view.finalResult?.outcome, "blocked"); - assert.equal(view.finalResult?.summary, "Write permission was not granted."); - assert.deepEqual(view.finalResult?.nextActions, ["Write permission was not granted."]); - assert.deepEqual(view.finalResult?.checks, [{ - id: step.id, - title: "Create file", - status: "blocked", - summary: "Write permission was not granted.", - }]); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view projects terminal failed run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-failed-run-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "修复失败任务"); - const run = store.createRun({ - id: "run_ling_agent_session_failed", - cwd, - taskId: "task_ling_agent_session_failed", - channelId: channel.id, - teammateId: "ling_agent", - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_ling_agent_session_failed", title: "Fix failing task" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-11T00:01:00.000Z", - }); - store.markRunFailed(run.id, "2026-06-11T00:02:00.000Z", "Typecheck failed."); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "failed"); - assert.equal(view.finalResult?.outcome, "failed"); - assert.equal(view.finalResult?.title, "Fix failing task"); - assert.equal(view.finalResult?.summary, "Typecheck failed."); - assert.deepEqual(view.finalResult?.nextActions, ["Typecheck failed."]); - assert.ok(view.evidenceRefs.some((ref) => ref.type === "run" && ref.id === run.id)); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent session view reports denied approval for terminal run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-session-denied-approval-")); - const store = createSqliteRunStore(cwd); - try { - const { channel } = createStoreChannelAndMessage(store, cwd, "创建 README.md"); - const run = store.createRun({ - id: "run_ling_agent_session_denied_approval", - cwd, - taskId: "task_ling_agent_session_denied_approval", - channelId: channel.id, - teammateId: "ling_agent", - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_ling_agent_session_denied_approval", title: "Create README.md" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-11T00:01:00.000Z", - }); - const approval = store.createApprovalRequest({ - runId: run.id, - taskId: run.taskId, - channelId: channel.id, - actionType: "write", - risk: "medium", - title: "Approve README.md write", - description: "Allow creating README.md.", - payload: { type: "file_write", path: `${cwd}/README.md`, bytes: 16 }, - expiresAt: "2999-01-01T00:00:00.000Z", - }, "2026-06-11T00:02:00.000Z"); - store.denyApprovalRequest(approval.id, { reason: "Need a narrower path.", resolvedBy: "local-user" }, "2026-06-11T00:03:00.000Z"); - store.markRunCancelled(run.id, "2026-06-11T00:04:00.000Z"); - - const view = buildLingAgentSessionViewForChannel({ store, channelId: channel.id }); - - assert.equal(view.status, "cancelled"); - assert.equal(view.finalResult?.outcome, "cancelled"); - assert.equal(view.finalResult?.summary, "Approval denied: Approve README.md write."); - assert.deepEqual(view.finalResult?.nextActions, ["Need a narrower path."]); - assert.deepEqual(view.finalResult?.approvals, [{ - approvalId: approval.id, - actionType: "write", - status: "denied", - title: "Approve README.md write", - }]); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent coordinator queues active run follow-up before ordinary routing", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-followup-")); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent Coordinator", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const run = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_ling_agent_coordinator_followup", - taskId: "task_ling_agent_coordinator_followup", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "完成后也更新 docs", - replyDisposition: { mode: "queue_followup", activeRunId: run.id }, - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.replyDispatch.disposition, "followup_queued"); - assert.equal(created.replyDispatch.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.mode, "queue_followup"); - const interventions = store.listRunInterventions({ sourceRunId: run.id }); - assert.deepEqual(interventions.map((intervention) => intervention.id), [created.replyDispatch.intervention.id]); - assert.deepEqual(store.listEvents(run.id).map((event) => event.type), ["run_intervention_created", "run_followup_queued"]); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("active channel reply does not intercept an unmarked direct workflow request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-active-reply-direct-workflow-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Direct workflow while reply is active", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const activeRun = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_active_reply_before_execute", - taskId: "task_active_reply_before_execute", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `在 ${cwd} 下创建 README.md`, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.replyDispatch, undefined); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); - assert.deepEqual(store.listRunInterventions({ sourceRunId: activeRun.id }), []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("root reply guidance cannot capture a branch turn and branch continuation stays on its thread", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-reply-branch-lineage-")); - await ensureGitRepo(cwd); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Reply branch lineage", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 4, - schedulingMode: "smart", - writeLockScope: "channel", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const branch = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/branches?cwd=${encodeURIComponent(cwd)}`, { - name: "Independent branch", - })).branch; - const store = stores.get(cwd); - assert.ok(store); - const rootRun = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_root_reply_before_branch", - taskId: "task_root_reply_before_branch", - }); - - const branchSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "thread", threadId: branch.rootThreadId }, - body: "Start an independent branch reply.", - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup", activeRunId: rootRun.id }, - }); - - assert.equal(branchSource.replyDispatch.disposition, "reply_requested"); - assert.equal(branchSource.aiReply.threadId, branch.rootThreadId); - assert.deepEqual(store.listRunInterventions({ sourceRunId: rootRun.id }), []); - await waitForCondition(() => { - const failed = store.listRuns({ limit: 20 }).find(run => run.threadId === branch.rootThreadId && run.status === "failed"); - assert.equal(failed?.errorMessage, undefined); - return runner.runs.length === 1; - }, "branch reply run to start"); - const branchRun = store.listRuns({ limit: 20 }).find((run) => - run.channelId === channel.id && run.threadId === branch.rootThreadId && run.id !== rootRun.id - ); - assert.ok(branchRun); - assert.equal(branchRun.threadId, branch.rootThreadId); - - const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "thread", threadId: branch.rootThreadId }, - body: "Continue only inside this branch.", - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup", activeRunId: branchRun.id }, - }); - assert.equal(guidance.replyDispatch.disposition, "followup_queued"); - assert.equal(guidance.replyDispatch.sourceRunId, branchRun.id); - - runner.resolve("Initial branch reply"); - await waitForCondition(() => runner.runs.length === 2, "branch continuation to start"); - await waitForCondition( - () => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.continuationRunId, - "branch continuation to link", - ); - const intervention = store.getRunIntervention(guidance.replyDispatch.intervention.id); - const continuationRun = store.getRun(intervention.continuationRunId); - assert.ok(continuationRun); - assert.equal(continuationRun.threadId, branch.rootThreadId); - runner.resolve("Branch continuation reply"); - } finally { - for (let index = 0; index < 3; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("topic channel replies keep canonical LingThread identity and isolate guidance by source message lineage", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-topic-reply-lineage-")); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Topic reply lineage", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - - const topicRoot = appendChannelItemFixture(store, { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "Topic root message.", - status: "ready", - }, "2026-08-08T00:00:00.000Z"); - const topicSource = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: topicRoot.id }, - body: "Start a reply inside this topic.", - requestAiReply: true, - providerPresetId: provider.id, - }); - await waitForCondition(() => runner.runs.length === 1, "topic reply run to start"); - const topicRun = store.listRuns({ limit: 20 }).find((run) => - run.channelId === channel.id && !run.parentRunId - ); - assert.ok(topicRun); - assert.equal(topicRun.requestSnapshot.sourceMessageId, topicSource.message.id); - assert.match(topicRun.threadId, /^thread_/); - assert.notEqual(topicRun.threadId, topicRoot.id); - - const guidance = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "thread", threadId: topicSource.thread.id }, - body: "Continue only inside this topic.", - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup", activeRunId: topicRun.id }, - }); - assert.equal(guidance.replyDispatch.disposition, "followup_queued"); - assert.equal(guidance.replyDispatch.sourceRunId, topicRun.id); - - runner.resolve("Initial topic reply"); - await waitForCondition(() => runner.runs.length === 2, "topic continuation to start"); - await waitForCondition( - () => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.continuationRunId, - "topic continuation to link", - ); - const intervention = store.getRunIntervention(guidance.replyDispatch.intervention.id); - const continuationRun = store.getRun(intervention.continuationRunId); - assert.equal(continuationRun.threadId, topicRun.threadId); - assert.equal(continuationRun.requestSnapshot.sourceMessageId, guidance.message.id); - runner.resolve("Topic continuation reply"); - } finally { - for (let index = 0; index < 3; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent coordinator queues attachment-only active run guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-attachment-followup-")); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent Coordinator Attachment", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const run = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_ling_agent_coordinator_attachment_followup", - taskId: "task_ling_agent_coordinator_attachment_followup", - }); - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "followup.txt", - contentType: "text/plain", - contentBase64: Buffer.from("active run follow-up context", "utf8").toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "", - attachmentIds: [attachment.id], - replyDisposition: { mode: "queue_followup", activeRunId: run.id }, - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.replyDispatch.disposition, "followup_queued"); - assert.equal(created.replyDispatch.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); - assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, [attachment.id]); - assert.equal(created.replyDispatch.intervention.messagePayload.body, ""); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent coordinator queues file-ref-only active run guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-file-ref-followup-")); - await writeFile(resolve(cwd, "README.md"), "# File ref guidance\n", "utf8"); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent Coordinator File Ref", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const run = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_ling_agent_coordinator_file_ref_followup", - taskId: "task_ling_agent_coordinator_file_ref_followup", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "", - fileRefs: [{ path: "README.md", label: "README.md", source: "composer" }], - replyDisposition: { mode: "queue_followup", activeRunId: run.id }, - }); - - assert.equal(created.aiReply, undefined); - assert.deepEqual(created.message.fileRefs, [{ path: "README.md", label: "README.md", source: "composer" }]); - assert.equal(created.replyDispatch.disposition, "followup_queued"); - assert.equal(created.replyDispatch.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.messagePayload.body, ""); - assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, []); - assert.deepEqual(created.replyDispatch.intervention.messagePayload.linkPreviewIds, []); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].path, "README.md"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].rootRelation, "inside_workspace"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].kind, "file"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].materializationStatus, "metadata_only"); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent turn envelope merges persisted and explicit text file refs safely", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-turn-file-refs-")); - await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); - const store = createSqliteRunStore(cwd); - try { - const channel = store.createChannel({ - name: "Ling Agent File Refs", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Also inspect file:README.md and path:/tmp/outside.txt", - fileRefs: [{ path: "README.md", label: "README.md", source: "composer" }], - }, "2026-06-11T00:01:00.000Z"); - - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); - - assert.deepEqual(envelope.fileRefs.map((ref) => ref.path), ["README.md", "/tmp/outside.txt"]); - assert.equal(envelope.fileRefs[0].source, "composer"); - assert.equal(envelope.fileRefs[0].rootRelation, "inside_workspace"); - assert.equal(envelope.fileRefs[0].kind, "file"); - assert.equal(envelope.fileRefs[1].source, "explicit_text"); - assert.equal(envelope.fileRefs[1].rootRelation, "outside_workspace"); - assert.equal(envelope.fileRefs[1].materializationStatus, "blocked"); - assert.match(envelope.fileRefs[1].blockedReason, /outside the active workspace/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel input materialization cache upserts and feeds turn envelope", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-input-materialization-cache-")); - const store = createSqliteRunStore(cwd); - try { - const channel = store.createChannel({ - name: "Materialization cache", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const attachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "screenshot.png", - contentType: "image/png", - sizeBytes: 128, - path: "screenshot.png", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const fingerprint = attachmentMaterializationFingerprint(attachment); - - const first = store.createChannelInputMaterialization({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - sourceFingerprint: fingerprint, - extractorId: "image_vision_summary_v1", - extractorVersion: "1", - contentKind: "vision_summary", - status: "available", - contentText: "The screenshot shows a save button overlapping the sidebar.", - byteCount: 128, - tokenEstimate: 12, - policyDecisionJson: { source: "test" }, - }, "2026-06-11T00:02:00.000Z"); - const second = store.createChannelInputMaterialization({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - sourceFingerprint: fingerprint, - extractorId: "image_vision_summary_v1", - extractorVersion: "1", - contentKind: "vision_summary", - status: "available", - contentText: "Updated screenshot summary from cache.", - truncated: true, - }, "2026-06-11T00:03:00.000Z"); - - assert.equal(second.id, first.id); - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].contentText, "Updated screenshot summary from cache."); - assert.equal(rows[0].truncated, true); - assert.deepEqual(first.policyDecisionJson, { source: "test" }); - - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Fix this screenshot", - attachmentIds: [attachment.id], - }, "2026-06-11T00:04:00.000Z"); - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); - - assert.equal(envelope.attachments.length, 1); - assert.equal(envelope.attachments[0].materializationStatus, "available"); - assert.equal(envelope.attachments[0].materializationId, first.id); - assert.equal(envelope.attachments[0].previewStatus, "available"); - assert.equal(envelope.attachments[0].previewText, "Updated screenshot summary from cache."); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("inline materialization writes image vision cache with injected summarizer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-inline-cache-")); - const store = createSqliteRunStore(cwd); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - await writeFile(resolve(store.paths.attachmentsDir, "inline.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - const channel = store.createChannel({ - name: "Image vision materialization", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const attachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "inline.png", - contentType: "image/png", - sizeBytes: 8, - path: "inline.png", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Please handle this.", - attachmentIds: [attachment.id], - }, "2026-06-11T00:02:00.000Z"); - - let summarizerCalls = 0; - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: message, - now: "2026-06-11T00:03:00.000Z", - imageVisionSummarizer: async (input) => { - summarizerCalls += 1; - assert.equal(input.attachment.id, attachment.id); - assert.equal(input.maxBytes, 5 * 1024 * 1024); - assert.equal(input.maxChars, 4_000); - assert.match(input.absolutePath, /inline\.png$/); - return { - contentText: "The screenshot shows a save button overlapping the sidebar.", - structuredSummary: { source: "test_summarizer" }, - policyDecision: { mode: "test" }, - }; - }, - }); - - assert.equal(summarizerCalls, 1); - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].extractorId, "image_vision_summary_v1"); - assert.equal(rows[0].contentKind, "vision_summary"); - assert.equal(rows[0].status, "available"); - assert.match(rows[0].contentText, /save button overlapping/); - assert.deepEqual(rows[0].structuredSummaryJson, { source: "test_summarizer" }); - assert.deepEqual(rows[0].policyDecisionJson, { mode: "test" }); - - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); - assert.equal(envelope.attachments[0].materializationStatus, "available"); - assert.equal(envelope.attachments[0].previewStatus, "available"); - assert.match(envelope.attachments[0].previewText, /save button overlapping/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("inline image materialization reuses same image content after provider timeout", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-content-cache-")); - const store = createSqliteRunStore(cwd); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - const imageBytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4]); - await writeFile(resolve(store.paths.attachmentsDir, "first.png"), imageBytes); - await writeFile(resolve(store.paths.attachmentsDir, "second.png"), imageBytes); - const channel = store.createChannel({ - name: "Image vision content cache", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const firstAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "same.png", - contentType: "image/png", - sizeBytes: imageBytes.byteLength, - path: "first.png", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const firstMessage = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "看下这个图片", - attachmentIds: [firstAttachment.id], - }, "2026-06-11T00:02:00.000Z"); - - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: firstMessage, - now: "2026-06-11T00:03:00.000Z", - imageVisionSummarizer: async () => ({ - contentText: "The image shows a red banner with visible Chinese text.", - structuredSummary: { source: "first_pass" }, - }), - }); - - const secondAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "same.png", - contentType: "image/png", - sizeBytes: imageBytes.byteLength, - path: "second.png", - createdAt: "2026-06-11T00:04:00.000Z", - }); - const secondMessage = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "同一张图再看一次", - attachmentIds: [secondAttachment.id], - }, "2026-06-11T00:05:00.000Z"); - - let timeoutSummarizerCalls = 0; - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: secondMessage, - now: "2026-06-11T00:06:00.000Z", - imageVisionSummarizer: async () => { - timeoutSummarizerCalls += 1; - throw new Error("The operation was aborted due to timeout"); - }, - }); - - assert.equal(timeoutSummarizerCalls, 0); - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: secondAttachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].status, "available"); - assert.match(rows[0].contentText, /red banner/); - - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: secondMessage }); - assert.equal(envelope.attachments[0].materializationStatus, "available"); - assert.match(envelope.attachments[0].previewText, /red banner/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("retryable image materialization failures are not cached permanently", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-vision-timeout-retry-")); - const store = createSqliteRunStore(cwd); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - const imageBytes = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 9, 8, 7, 6]); - await writeFile(resolve(store.paths.attachmentsDir, "retry.png"), imageBytes); - const channel = store.createChannel({ - name: "Image vision timeout retry", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const attachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "retry.png", - contentType: "image/png", - sizeBytes: imageBytes.byteLength, - path: "retry.png", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "看下这个图片", - attachmentIds: [attachment.id], - }, "2026-06-11T00:02:00.000Z"); - - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: message, - now: "2026-06-11T00:03:00.000Z", - imageVisionSummarizer: async () => { - throw new Error("The operation was aborted due to timeout"); - }, - }); - assert.equal(store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }).length, 0); - - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: message, - now: "2026-06-11T00:04:00.000Z", - imageVisionSummarizer: async () => ({ - contentText: "The retried image summary is available.", - }), - }); - - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].status, "available"); - assert.match(rows[0].contentText, /retried image summary/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("teammate direct materialization reads image attachments with injected summarizer", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-direct-image-vision-")); - const store = createSqliteRunStore(cwd); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - await writeFile(resolve(store.paths.attachmentsDir, "direct-image.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - const teammate = store.createAiTeammate({ - roleKey: "engineer", - displayName: "Engineer", - description: "Helps with private chat.", - avatarTone: "blue", - approvalProfile: { mode: "none" }, - status: "active", - }, "2026-06-11T01:00:00.000Z"); - const thread = store.getOrCreateTeammateDirectThread(teammate.id, "2026-06-11T01:01:00.000Z"); - const message = store.createTeammateDirectMessage({ - threadId: thread.id, - teammateId: teammate.id, - authorType: "local-user", - authorId: "local-user", - body: "Look at this screenshot.", - status: "ready", - }, "2026-06-11T01:02:00.000Z"); - const attachment = store.createTeammatePrivateAttachment({ - teammateId: teammate.id, - threadId: thread.id, - messageId: message.id, - scope: "teammate_direct_thread", - kind: "image", - fileName: "direct-image.png", - contentType: "image/png", - sizeBytes: 8, - path: "direct-image.png", - createdAt: "2026-06-11T01:03:00.000Z", - }); - const messageWithAttachment = store.createTeammateDirectMessage({ - threadId: thread.id, - teammateId: teammate.id, - authorType: "local-user", - authorId: "local-user", - body: "Please read this image.", - attachmentIds: [attachment.id], - status: "ready", - }, "2026-06-11T01:04:00.000Z"); - - let summarizerCalls = 0; - const materialized = await materializeInlineTeammateDirectInputs({ - store, - teammateId: teammate.id, - threadId: thread.id, - sourceMessage: messageWithAttachment, - now: "2026-06-11T01:05:00.000Z", - imageVisionSummarizer: async (input) => { - summarizerCalls += 1; - assert.equal(input.attachment.id, attachment.id); - assert.equal(input.maxBytes, 5 * 1024 * 1024); - assert.equal(input.maxChars, 4_000); - assert.match(input.absolutePath, /direct-image\.png$/); - return { - contentText: "The image shows an error toast above a disabled send button.", - structuredSummary: { source: "direct_test_summarizer" }, - }; - }, - }); - - assert.equal(summarizerCalls, 1); - const result = materialized.get(attachment.id); - assert.equal(result?.status, "available"); - assert.equal(result?.kind, "image"); - assert.match(result?.contentText ?? "", /disabled send button/); - } finally { - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("public link body extractor strips HTML and blocks unsafe redirects", async () => { - const linkPreview = { - id: "link_preview_public_docs", - workspaceId: "workspace_default", - channelId: "channel_docs", - url: "https://example.com/docs", - normalizedUrl: "https://example.com/docs", - title: "Docs", - status: "ready", - createdAt: "2026-06-11T00:00:00.000Z", - updatedAt: "2026-06-11T00:00:00.000Z", - }; - const success = await linkBodyExtractorV1.materialize({ - workspaceId: "workspace_default", - channelId: "channel_docs", - sourceKind: "link_preview", - sourceId: linkPreview.id, - sourceFingerprint: "fingerprint", - requestedBy: "turn_envelope", - maxBytes: 4096, - maxChars: 1000, - now: "2026-06-11T00:00:00.000Z", - linkPreview, - resolveHostAddresses: async () => ["93.184.216.34"], - fetch: async () => new Response(` - - Docs - - -

Public guide

Use this body for the summary.

- - - `, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }), - }); - - assert.equal(success.status, "available"); - assert.equal(success.contentKind, "html_text"); - assert.match(success.contentText, /Public guide/); - assert.match(success.contentText, /Use this body for the summary/); - assert.doesNotMatch(success.contentText, /window\.secret|Navigation noise/); - - let calls = 0; - const redirected = await linkBodyExtractorV1.materialize({ - workspaceId: "workspace_default", - channelId: "channel_docs", - sourceKind: "link_preview", - sourceId: linkPreview.id, - sourceFingerprint: "fingerprint", - requestedBy: "turn_envelope", - maxBytes: 4096, - maxChars: 1000, - now: "2026-06-11T00:00:00.000Z", - linkPreview, - resolveHostAddresses: async () => ["93.184.216.34"], - fetch: async () => { - calls += 1; - return new Response(null, { - status: 302, - headers: { location: "http://127.0.0.1/private" }, - }); - }, - }); - - assert.equal(calls, 1); - assert.equal(redirected.status, "blocked"); - assert.equal(redirected.errorCode, "local_or_private_host"); - - let privateFetchCalled = false; - const privateBlocked = await linkBodyExtractorV1.materialize({ - workspaceId: "workspace_default", - channelId: "channel_docs", - sourceKind: "link_preview", - sourceId: "link_preview_private", - sourceFingerprint: "fingerprint", - requestedBy: "turn_envelope", - maxBytes: 4096, - maxChars: 1000, - now: "2026-06-11T00:00:00.000Z", - linkPreview: { - ...linkPreview, - id: "link_preview_private", - url: "http://127.0.0.1/private", - normalizedUrl: "http://127.0.0.1/private", - }, - fetch: async () => { - privateFetchCalled = true; - return new Response("should not fetch", { status: 200 }); - }, - }); - - assert.equal(privateBlocked.status, "blocked"); - assert.equal(privateBlocked.errorCode, "local_or_private_host"); - assert.equal(privateFetchCalled, false); -}); - -test("A11 preview and body fresh native connections consume audited DNS addresses", () => { - for (const caseName of ["preview-native", "body-native"]) { - const childEnv = { ...process.env, LING_A11_SOCKET_CASE: caseName }; - delete childEnv.NODE_TEST_CONTEXT; - const result = spawnSync(process.execPath, ["--test", "--test-name-pattern=^A11 fixture child$", - new URL("./media-native-transport.test.mjs", import.meta.url).pathname], { - env: childEnv, encoding: "utf8", timeout: 20_000, - }); - assert.equal(result.error, undefined, `${caseName}: ${result.error?.message}`); - assert.match(result.stdout, new RegExp(`A11 child entered: ${caseName}`), "child must execute its socket assertions"); - assert.equal(result.status, 0, `${caseName}\n${result.stdout}\n${result.stderr}`); - } -}); - -test("inline materialization writes public link body cache for explicit link request", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-inline-cache-")); - const store = createSqliteRunStore(cwd); - try { - const channel = store.createChannel({ - name: "Link body materialization", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const linkPreview = store.createChannelLinkPreview({ - channelId: channel.id, - url: "https://example.com/docs", - normalizedUrl: "https://example.com/docs", - title: "Example Docs", - description: "Metadata only before body extraction.", - siteName: "Example", - status: "ready", - }, "2026-06-11T00:01:00.000Z"); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Please summarize this link", - linkPreviewIds: [linkPreview.id], - }, "2026-06-11T00:02:00.000Z"); - - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: message, - now: "2026-06-11T00:03:00.000Z", - resolveHostAddresses: async () => ["93.184.216.34"], - fetch: async () => new Response("

Inline public body cache.

", { - status: 200, - headers: { "content-type": "text/html; charset=utf-8" }, - }), - }); - - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "link_preview", - sourceId: linkPreview.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].extractorId, "link_body_v1"); - assert.equal(rows[0].status, "available"); - assert.match(rows[0].contentText, /Inline public body cache/); - - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); - assert.equal(envelope.linkPreviews[0].materializationStatus, "available"); - assert.equal(envelope.linkPreviews[0].materializationId, rows[0].id); - assert.match(envelope.linkPreviews[0].previewText, /Inline public body cache/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("cached public link body lets Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-link-body-cached-reply-")); - const runner = new ScriptedRunner(["这个页面讲的是 cached public link body。"]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { defaultWorkspaceCwd: cwd }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Cached link body reply", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const linkPreview = store.createChannelLinkPreview({ - channelId: channel.id, - url: "https://example.com/guide", - normalizedUrl: "https://example.com/guide", - title: "Guide", - description: "Metadata before cached body.", - siteName: "Example", - status: "ready", - }, "2026-06-11T00:01:00.000Z"); - store.createChannelInputMaterialization({ - channelId: channel.id, - sourceKind: "link_preview", - sourceId: linkPreview.id, - sourceFingerprint: linkPreviewMaterializationFingerprint(linkPreview), - extractorId: "link_body_v1", - extractorVersion: "1", - contentKind: "html_text", - status: "available", - contentText: "Cached public link body for Ling to summarize.", - byteCount: 48, - tokenEstimate: 12, - }, "2026-06-11T00:02:00.000Z"); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Please summarize this link", - linkPreviewIds: [linkPreview.id], - }); - - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "这个页面讲的是 cached public link body。"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Cached public link body for Ling to summarize/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("pdf and OOXML extractors materialize bounded attachment text", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-extractors-")); - const store = createSqliteRunStore(cwd); - const officeArtifacts = new OfficeArtifactService(); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - const channel = store.createChannel({ - name: "Document extractors", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - - const pdfBytes = await readPdfFixtureBytes(); - await writeFile(resolve(store.paths.attachmentsDir, "fixture.pdf"), pdfBytes); - const pdfAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "pdf", - fileName: "fixture.pdf", - contentType: "application/pdf", - sizeBytes: pdfBytes.byteLength, - path: "fixture.pdf", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const pdfResult = await pdfExtractorV1.materialize({ - workspaceId: pdfAttachment.workspaceId, - channelId: channel.id, - sourceKind: "attachment", - sourceId: pdfAttachment.id, - sourceFingerprint: "pdf-fingerprint", - requestedBy: "turn_envelope", - maxBytes: 128 * 1024, - maxChars: 100, - now: "2026-06-11T00:02:00.000Z", - attachment: pdfAttachment, - attachmentsDir: store.paths.attachmentsDir, - }); - assert.equal(pdfResult.status, "available", JSON.stringify(pdfResult)); - assert.equal(pdfResult.contentKind, "pdf_text"); - assert.match(pdfResult.contentText, /Ling PDF fixture/); - assert.equal(pdfResult.structuredSummary.pageCount, 1); - - const docxBytes = await readDocxFixtureBytes(); - await writeFile(resolve(store.paths.attachmentsDir, "fixture.docx"), docxBytes); - const docxAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "office", - fileName: "fixture.docx", - contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - sizeBytes: docxBytes.byteLength, - path: "fixture.docx", - createdAt: "2026-06-11T00:03:00.000Z", - }); - const docxResult = await officeExtractorV1.materialize({ - workspaceId: docxAttachment.workspaceId, - channelId: channel.id, - sourceKind: "attachment", - sourceId: docxAttachment.id, - sourceFingerprint: "docx-fingerprint", - requestedBy: "turn_envelope", - maxBytes: 128 * 1024, - maxChars: 100, - now: "2026-06-11T00:04:00.000Z", - attachment: docxAttachment, - attachmentsDir: store.paths.attachmentsDir, - }); - assert.equal(docxResult.status, "available", JSON.stringify(docxResult)); - assert.equal(docxResult.contentKind, "office_text"); - assert.match(docxResult.contentText, /Walking on imported air/); - assert.deepEqual(docxResult.structuredSummary, { format: "docx", warnings: [] }); - - const pptxPath = resolve(store.paths.attachmentsDir, "slides.pptx"); - await officeArtifacts.createPresentation(cwd, { - path: pptxPath, - title: "Ling Office fixture", - slides: [{ title: "Native presentation", bullets: ["Bounded extraction"] }], - }); - const pptxBytes = await readFile(pptxPath); - const pptxAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "office", - fileName: "slides.pptx", - contentType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - sizeBytes: pptxBytes.byteLength, - path: "slides.pptx", - createdAt: "2026-06-11T00:05:00.000Z", - }); - const pptxResult = await officeExtractorV1.materialize({ - workspaceId: pptxAttachment.workspaceId, - channelId: channel.id, - sourceKind: "attachment", - sourceId: pptxAttachment.id, - sourceFingerprint: "pptx-fingerprint", - requestedBy: "turn_envelope", - maxBytes: 512 * 1024, - maxChars: 100, - now: "2026-06-11T00:06:00.000Z", - attachment: pptxAttachment, - attachmentsDir: store.paths.attachmentsDir, - }); - assert.equal(pptxResult.status, "available", JSON.stringify(pptxResult)); - assert.match(pptxResult.contentText, /Native presentation/u); - assert.deepEqual(pptxResult.structuredSummary, { format: "pptx", warnings: [] }); - - const xlsxPath = resolve(store.paths.attachmentsDir, "metrics.xlsx"); - await officeArtifacts.createWorkbook(cwd, { - path: xlsxPath, - sheets: [{ name: "Summary", rows: [["Metric", "Value"], ["Active users", 42]] }], - }); - const xlsxBytes = await readFile(xlsxPath); - const xlsxAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "office", - fileName: "metrics.xlsx", - contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - sizeBytes: xlsxBytes.byteLength, - path: "metrics.xlsx", - createdAt: "2026-06-11T00:07:00.000Z", - }); - const xlsxResult = await officeExtractorV1.materialize({ - workspaceId: xlsxAttachment.workspaceId, - channelId: channel.id, - sourceKind: "attachment", - sourceId: xlsxAttachment.id, - sourceFingerprint: "xlsx-fingerprint", - requestedBy: "turn_envelope", - maxBytes: 512 * 1024, - maxChars: 100, - now: "2026-06-11T00:08:00.000Z", - attachment: xlsxAttachment, - attachmentsDir: store.paths.attachmentsDir, - }); - assert.equal(xlsxResult.status, "available", JSON.stringify(xlsxResult)); - assert.match(xlsxResult.contentText, /Active users/u); - assert.deepEqual(xlsxResult.structuredSummary, { format: "xlsx", warnings: [] }); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("inline materialization writes pdf and docx cache for current-turn attachments", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-inline-cache-")); - const store = createSqliteRunStore(cwd); - try { - await mkdir(store.paths.attachmentsDir, { recursive: true }); - const channel = store.createChannel({ - name: "Document inline cache", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - }, "2026-06-11T00:00:00.000Z"); - const pdfBytes = await readPdfFixtureBytes(); - await writeFile(resolve(store.paths.attachmentsDir, "inline.pdf"), pdfBytes); - const pdfAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "pdf", - fileName: "inline.pdf", - contentType: "application/pdf", - sizeBytes: pdfBytes.byteLength, - path: "inline.pdf", - createdAt: "2026-06-11T00:01:00.000Z", - }); - const docxBytes = await readDocxFixtureBytes(); - await writeFile(resolve(store.paths.attachmentsDir, "inline.docx"), docxBytes); - const docxAttachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "office", - fileName: "inline.docx", - contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - sizeBytes: docxBytes.byteLength, - path: "inline.docx", - createdAt: "2026-06-11T00:02:00.000Z", - }); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Context attached.", - attachmentIds: [pdfAttachment.id, docxAttachment.id], - }, "2026-06-11T00:03:00.000Z"); - - await materializeInlineChannelInputs({ - store, - channel, - sourceMessage: message, - now: "2026-06-11T00:04:00.000Z", - }); - - const pdfRows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: pdfAttachment.id, - }); - assert.equal(pdfRows.length, 1); - assert.equal(pdfRows[0].extractorId, "pdf_text_v1"); - assert.equal(pdfRows[0].status, "available", JSON.stringify(pdfRows[0])); - assert.match(pdfRows[0].contentText, /Ling PDF fixture/); - - const docxRows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: docxAttachment.id, - }); - assert.equal(docxRows.length, 1); - assert.equal(docxRows[0].extractorId, "office_text_v1"); - assert.equal(docxRows[0].status, "available", JSON.stringify(docxRows[0])); - assert.match(docxRows[0].contentText, /Walking on imported air/); - - const envelope = buildLingAgentTurnEnvelope({ store, channel, sourceMessage: message }); - assert.deepEqual(envelope.attachments.map((attachment) => attachment.materializationStatus), ["available", "available"]); - assert.match(envelope.attachments[0].previewText, /Ling PDF fixture/); - assert.match(envelope.attachments[1].previewText, /Walking on imported air/); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("materialized pdf and docx attachments let Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-document-materialized-reply-")); - const runner = new ScriptedRunner(["我读到了 PDF 和 DOCX 的正文。"]); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Materialized document reply", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - const pdfAttachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "guide.pdf", - contentType: "application/pdf", - contentBase64: (await readPdfFixtureBytes()).toString("base64"), - })).attachment; - const docxAttachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "brief.docx", - contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - contentBase64: (await readDocxFixtureBytes()).toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Please summarize this PDF and this document", - attachmentIds: [pdfAttachment.id, docxAttachment.id], - }); - - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "我读到了 PDF 和 DOCX 的正文。"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Ling PDF fixture/); - assert.match(runner.runs[0].task.prompt, /Walking on imported air/); - const pdfPreview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(pdfAttachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=channel&channelId=${encodeURIComponent(channel.id)}`); - assert.equal(pdfPreview.kind, "pdf"); - assert.match(pdfPreview.text, /Ling PDF fixture/); - const docxPreview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(docxAttachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=channel&channelId=${encodeURIComponent(channel.id)}`); - assert.equal(docxPreview.kind, "office"); - assert.match(docxPreview.text, /Walking on imported air/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("materialized image attachment lets Ling answer without materialization clarify", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-image-materialized-reply-")); - const runner = new ScriptedRunner(["我读到了截图摘要。"]); - const daemon = await startDaemonForTest(runner, { - imageVisionSummarizer: async () => ({ - contentText: "The screenshot shows the branch button hidden next to the channel title.", - structuredSummary: { source: "test_summarizer" }, - }), - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Materialized image reply", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "screenshot.png", - contentType: "image/png", - contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Please fix the UI from this screenshot", - attachmentIds: [attachment.id], - requestAiReply: true, - }); - - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "我读到了截图摘要。"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /branch button hidden/); - assert.doesNotMatch(runner.runs[0].task.prompt, /Image vision summarizer is not configured/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("provider-backed image materialization works without injected summarizer", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-provider-backed-image-materialization-")); - const relay = await startMockOpenAICompatibleServer(t, { - responseContent: ({ body }) => { - const content = body?.messages?.[0]?.content; - if (Array.isArray(content) && content.some((part) => part?.type === "image_url")) { - return "The screenshot shows a settings dialog with the permission section highlighted."; - } - return "OK"; - }, - }); - const runner = new ScriptedRunner(["我读到了 provider 视觉摘要。"]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - kind: "openai-compatible", - displayName: "Vision Relay", - providerId: "relay", - model: "vision-chat", - api: "openai-completions", - baseUrl: `${relay.url}/v1`, - capabilities: ["chat"], - input: ["text"], - authHeader: false, - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Provider backed image reply", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "settings.png", - contentType: "image/png", - contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "为什么还是这样?", - attachmentIds: [attachment.id], - requestAiReply: true, - }); - - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "我读到了 provider 视觉摘要。"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /permission section highlighted/); - assert.doesNotMatch(runner.runs[0].task.prompt, /contents aren't available|Image vision summarizer is not configured/i); - - const store = stores.get(cwd); - assert.ok(store); - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].extractorId, "image_vision_summary_v1"); - assert.equal(rows[0].status, "available", JSON.stringify(rows[0])); - assert.match(rows[0].contentText, /settings dialog/); - - const visionRequest = relay.requests.find((request) => Array.isArray(request.body?.messages?.[0]?.content)); - assert.ok(visionRequest); - const imagePart = visionRequest.body.messages[0].content.find((part) => part.type === "image_url"); - assert.match(imagePart.image_url.url, /^data:image\/png;base64,/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent coordinator preserves materialized guidance metadata for active run continuation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-materialized-guidance-")); - await writeFile(resolve(cwd, "README.md"), "# Current README\n", "utf8"); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent Materialized Guidance", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const run = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_ling_agent_materialized_guidance", - taskId: "task_ling_agent_materialized_guidance", - }); - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "followup.txt", - contentType: "text/plain", - contentBase64: Buffer.from("active run follow-up context", "utf8").toString("base64"), - })).attachment; - const linkPreview = store.createChannelLinkPreview({ - channelId: channel.id, - url: "https://example.com/docs", - normalizedUrl: "https://example.com/docs", - title: "Example Docs", - description: "Cached link-preview metadata only.", - siteName: "Example", - status: "ready", - }, "2026-06-11T00:02:30.000Z"); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "另外也更新 file:README.md", - attachmentIds: [attachment.id], - linkPreviewIds: [linkPreview.id], - replyDisposition: { mode: "queue_followup", activeRunId: run.id }, - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.replyDispatch.disposition, "followup_queued"); - assert.equal(created.replyDispatch.intervention.sourceRunId, run.id); - assert.deepEqual(created.replyDispatch.intervention.messagePayload.attachmentIds, [attachment.id]); - assert.deepEqual(created.replyDispatch.intervention.messagePayload.linkPreviewIds, [linkPreview.id]); - assert.equal(created.replyDispatch.intervention.messagePayload.attachments[0].fileName, "followup.txt"); - assert.equal(created.replyDispatch.intervention.messagePayload.attachments[0].materializationStatus, "available"); - assert.match(created.replyDispatch.intervention.messagePayload.attachments[0].previewText, /active run follow-up context/); - assert.equal(created.replyDispatch.intervention.messagePayload.linkPreviews[0].title, "Example Docs"); - assert.equal(created.replyDispatch.intervention.messagePayload.linkPreviews[0].materializationStatus, "metadata_only"); - assert.match(created.replyDispatch.intervention.messagePayload.linkPreviews[0].previewText, /Cached link-preview metadata only/); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].path, "README.md"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].rootRelation, "inside_workspace"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].kind, "file"); - assert.equal(created.replyDispatch.intervention.messagePayload.fileRefs[0].materializationStatus, "metadata_only"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("ling agent coordinator treats redirect-like text as queued guidance", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ling-agent-coordinator-redirect-")); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent Coordinator Redirect", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const run = createActiveChannelReplyRunFixture(store, { - cwd, - channelId: channel.id, - runId: "run_ling_agent_coordinator_redirect", - taskId: "task_ling_agent_coordinator_redirect", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "停,改用 daemon API", - replyDisposition: { mode: "queue_followup", activeRunId: run.id }, - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.replyDispatch.disposition, "followup_queued"); - assert.equal(created.replyDispatch.sourceRunId, run.id); - assert.equal(created.replyDispatch.intervention.mode, "queue_followup"); - assert.equal(created.replyDispatch.intervention.instruction, "停,改用 daemon API"); - assert.equal(store.getRun(run.id).status, "running"); - assert.deepEqual(store.listRunInterventions({ sourceRunId: run.id }).map((item) => item.id), [created.replyDispatch.intervention.id]); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel reply guidance drains FIFO without interrupting and projects every continuation reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-fifo-")); - await ensureGitRepo(cwd); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - const designerProvider = (await createStaticChatProvider(daemon.url, cwd, { - displayName: "Designer Static Chat", - providerId: "designer-static-chat", - model: "designer-static-model", - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await postJson(`${daemon.url}/providers/${encodeURIComponent(designerProvider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Channel reply guidance FIFO", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read"], - })).channel; - const designerId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(designerId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: designerProvider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(designerId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - - const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Start a delayed Ling reply.", - requestAiReply: true, - teammateId: "ling_agent", - providerPresetId: provider.id, - }); - assert.equal(source.aiReply.status, "pending"); - await waitForCondition(() => runner.runs.length === 1, "source channel reply to start"); - const sourceRun = store.listRuns({ limit: 20 }) - .find((run) => run.channelId === channel.id && !run.parentRunId); - assert.ok(sourceRun); - const sourceMessage = getChannelItemViewFixture(store, sourceRun.requestSnapshot.sourceMessageId); - assert.ok(sourceMessage); - assert.equal(findActiveChannelReplyRun({ - store, - channelId: channel.id, - sourceMessage, - activeRunId: sourceRun.id, - })?.id, sourceRun.id); - - const queuedInputs = [ - { - body: "@Designer queued channel guidance one.", - mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], - targetTeammateId: designerId, - }, - { - body: "Queued channel guidance two.", - mentions: [], - targetTeammateId: "ling_agent", - }, - ]; - const queued = []; - for (const queuedInput of queuedInputs) { - const response = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: queuedInput.body, - mentions: queuedInput.mentions, - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, - }); - assert.equal(response.replyDispatch.disposition, "followup_queued"); - assert.equal(response.replyDispatch.targetTeammateId, queuedInput.targetTeammateId); - assert.equal(response.replyDispatch.intervention.messagePayload.targetTeammateId, queuedInput.targetTeammateId); - queued.push(response); - } - assert.equal(runner.runs.length, 1); - assert.equal(runner.abortCount, 0); - const queuedMessageIds = new Set(queued.map((response) => response.message.id)); - const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual(whileQueued.messages.filter((message) => queuedMessageIds.has(message.id)), []); - - runner.resolve("Initial channel reply"); - await waitForCondition(() => runner.runs.length === 2, "first channel continuation to start"); - assert.match(runner.runs[1].task.prompt, /@Designer queued channel guidance one\./); - assert.equal(readCurrentChannelUserMessage(runner.runs[1].task.prompt), queuedInputs[0].body); - await waitForCondition( - () => store.getRunIntervention(queued[0].replyDispatch.intervention.id)?.continuationRunId, - "first channel continuation to link", - ); - const firstContinuation = store.getRun( - store.getRunIntervention(queued[0].replyDispatch.intervention.id).continuationRunId, - ); - assert.equal(firstContinuation.teammateId, designerId); - assert.equal(firstContinuation.providerPresetId, designerProvider.id); - assert.doesNotMatch(runner.runs[1].task.prompt, /Continue a Ling Stack supervised run from preserved context\./); - assert.match(runner.runs[1].systemPrompt, /Treat the latest queued follow-up as the current user message/); - const whileFirstRuns = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual( - whileFirstRuns.messages.filter((message) => queuedMessageIds.has(message.id)).map((message) => message.id), - [queued[0].message.id], - ); - runner.resolve("First channel continuation reply"); - - await waitForCondition(() => runner.runs.length === 3, "second channel continuation to start"); - assert.match(runner.runs[2].task.prompt, /Queued channel guidance two\./); - assert.equal(readCurrentChannelUserMessage(runner.runs[2].task.prompt), queuedInputs[1].body); - await waitForCondition( - () => store.getRunIntervention(queued[1].replyDispatch.intervention.id)?.continuationRunId, - "second channel continuation to link", - ); - const secondContinuation = store.getRun( - store.getRunIntervention(queued[1].replyDispatch.intervention.id).continuationRunId, - ); - assert.equal(secondContinuation.teammateId, "ling_agent"); - assert.equal(secondContinuation.providerPresetId, provider.id); - assert.doesNotMatch(runner.runs[2].task.prompt, /Continue a Ling Stack supervised run from preserved context\./); - assert.match(runner.runs[2].systemPrompt, /Treat the latest queued follow-up as the current user message/); - assert.match( - runner.runs[2].task.prompt, - /Conversation context selected by Continuity v2[\s\S]*First channel continuation reply/, - ); - runner.resolve("Second channel continuation reply"); - - await waitForCondition( - () => store.listRunInterventions({ sourceRunId: queued[0].replyDispatch.sourceRunId }).every((item) => item.completedAt), - "channel guidance interventions to complete", - ); - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - const visibleAiBodies = listed.messages - .filter((message) => message.authorType === "ai-teammate" && message.status === "ready") - .map((message) => message.body); - assert.deepEqual(visibleAiBodies, [ - "Initial channel reply", - "First channel continuation reply", - "Second channel continuation reply", - ]); - for (const [index, response] of queued.entries()) { - const intervention = store.getRunIntervention(response.replyDispatch.intervention.id); - assert.equal(intervention.status, "started"); - assert.ok(intervention.completedAt); - assert.equal(intervention.messagePayload.sourceMessageId, response.message.id); - const continuationRun = store.getRun(intervention.continuationRunId); - const continuationWorkUnit = store.getWorkUnit(intervention.continuationWorkUnitId); - assert.equal(continuationRun.status, "succeeded"); - assert.equal(continuationWorkUnit.ownerId, response.message.id); - assert.ok(continuationWorkUnit.linkedMessageId); - const reply = getChannelItemViewFixture(store, continuationWorkUnit.linkedMessageId); - assert.equal(reply.body, `${index === 0 ? "First" : "Second"} channel continuation reply`); - assert.equal(reply.linkedRunId, continuationRun.id); - } - assert.equal(runner.abortCount, 0); - } finally { - for (let index = 0; index < 3; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel reply guidance continues draining after the first continuation fails to start", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-guidance-start-failure-")); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Channel reply guidance start failure", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - - await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Start a delayed reply before draining guidance.", - requestAiReply: true, - providerPresetId: provider.id, - }); - await waitForCondition(() => runner.runs.length === 1, "source reply to start before failure drain"); - const sourceRun = store.listRuns({ limit: 20 }).find((run) => run.channelId === channel.id && !run.parentRunId); - assert.ok(sourceRun); - - const invalid = store.createRunIntervention({ - sourceRunId: sourceRun.id, - mode: "queue_followup", - instruction: "This source message is intentionally missing.", - messagePayload: { - sourceMessageId: "msg_missing_guidance_source", - targetTeammateId: "ling_agent", - body: "This source message is intentionally missing.", - attachmentIds: [], - linkPreviewIds: [], - }, - idempotencyKey: "channel_message:msg_missing_guidance_source:queue_followup", - requestedBy: "local-user", - }, "2026-08-08T00:00:00.000Z"); - const valid = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "This second queued message must still run.", - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, - }); - assert.equal(valid.replyDispatch.disposition, "followup_queued"); - - runner.resolve("Initial reply before failure drain"); - await waitForCondition( - () => store.getRunIntervention(invalid.id)?.status === "failed", - "invalid first guidance to fail", - ); - await waitForCondition(() => runner.runs.length === 2, "second guidance to start after first failure"); - assert.match(runner.runs[1].task.prompt, /This second queued message must still run\./); - const validInterventionId = valid.replyDispatch.intervention.id; - await waitForCondition( - () => Boolean(store.getRunIntervention(validInterventionId)?.continuationRunId), - "second guidance continuation to link after first failure", - ); - assert.equal(store.getRunIntervention(invalid.id)?.failureReason.includes("invalid"), true); - assert.equal(store.getRunIntervention(validInterventionId)?.status, "started"); - runner.resolve("Second guidance completed"); - } finally { - for (let index = 0; index < 3; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("queued channel source stays hidden while dispatch classification is still in flight", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-classification-visibility-")); - await ensureGitRepo(cwd); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - let releaseVision; - let visionStartedCount = 0; - const visionRelease = new Promise((resolve) => { - releaseVision = resolve; - }); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - imageVisionSummarizer: async () => { - visionStartedCount += 1; - await visionRelease; - return { - contentText: "A screenshot used to hold dispatch classification open.", - structuredSummary: { source: "classification_visibility_test" }, - }; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Classification visibility", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read"], - })).channel; - const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Start a delayed reply before classification visibility is tested.", - requestAiReply: true, - teammateId: "ling_agent", - providerPresetId: provider.id, - }); - assert.equal(source.aiReply.status, "pending"); - await waitForCondition(() => runner.runs.length === 1, "source channel reply to start"); - - const store = stores.get(cwd); - assert.ok(store); - const sourceRun = store.listRuns({ limit: 20 }) - .find((run) => run.channelId === channel.id && run.requestSnapshot.sourceMessageId === source.message.id); - assert.ok(sourceRun); - await mkdir(store.paths.attachmentsDir, { recursive: true }); - await writeFile(resolve(store.paths.attachmentsDir, "classification.png"), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); - const attachment = store.createChannelAttachment({ - channelId: channel.id, - kind: "image", - fileName: "classification.png", - contentType: "image/png", - sizeBytes: 8, - path: "classification.png", - createdAt: new Date().toISOString(), - }); - const queuedBody = "Queue this message while dispatch classification is blocked."; - const streamResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: queuedBody, - attachmentIds: [attachment.id], - requestAiReply: true, - replyDisposition: { mode: "queue_followup", activeRunId: sourceRun.id }, - }), - }); - assert.equal(streamResponse.status, 200); - await waitForCondition(() => visionStartedCount === 1, "classification image summarizer to start"); - - const whileClassifying = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.equal(whileClassifying.messages.some((message) => message.body === queuedBody), false); - - releaseVision(); - const events = parseSseEvents(await streamResponse.text()); - const terminal = events.at(-1); - assert.equal(terminal.event, "done"); - assert.equal(terminal.data.replyDispatch.disposition, "followup_queued"); - const whileQueued = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.equal(whileQueued.messages.some((message) => message.id === terminal.data.message.id), false); - } finally { - releaseVision?.(); - for (let index = 0; index < 3; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("unmentioned channel guidance joins the pending Ling reply lane before its run is linked", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-pending-lane-")); - await ensureGitRepo(cwd); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - let webSearchCallCount = 0; - let releaseWebSearch; - const webSearchRelease = new Promise((resolve) => { - releaseWebSearch = resolve; - }); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - secretResolver: createCompositeSecretResolver({ env: { OPENAI_API_KEY: "sk-test-secret-value" } }), - channelWebFetch: async () => { - webSearchCallCount += 1; - await webSearchRelease; - return new Response(JSON.stringify({ - output_text: "Delayed web evidence.", - output: [{ - type: "web_search_call", - action: { - type: "search", - sources: [{ type: "url", url: "https://example.com/delayed", title: "Delayed source" }], - }, - }], - }), { status: 200, headers: { "content-type": "application/json" } }); - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - kind: "openai", - displayName: "Delayed web reply", - providerId: "openai", - model: "gpt-5.5", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - apiKeyRef: "env:OPENAI_API_KEY", - authHeader: true, - capabilities: ["chat", "tool_call", "reasoning", "web_search"], - })).provider; - const store = stores.get(cwd); - assert.ok(store); - store.recordProviderCheckResult(provider.id, { - ok: true, - code: "static_check", - message: "Provider ready for pending reply lane test.", - checkedAt: new Date().toISOString(), - }); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - - const sourcePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "查一下最新版本说明。", - requestAiReply: true, - providerPresetId: provider.id, - }); - await waitForCondition(() => webSearchCallCount === 1, "source reply web search to start"); - const guidancePromise = postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "After that, reply only with pending lane guidance.", - requestAiReply: true, - providerPresetId: provider.id, - replyDisposition: { mode: "queue_followup" }, - }); - - releaseWebSearch(); - await waitForCondition(() => runner.runs.length === 1, "source Ling reply run to start"); - const sourceRun = store.listRuns({ teammateId: "ling_agent", limit: 20 }) - .find((run) => run.channelId === channel.id && !run.parentRunId); - assert.ok(sourceRun); - assert.equal(sourceRun.teammateId, "ling_agent"); - assert.ok(sourceRun.workUnitId); - assert.equal(store.getWorkUnit(sourceRun.workUnitId).teammateId, "ling_agent"); - assert.equal(store.listRuns({ teammateId: "ai-teammate", limit: 20 }).length, 0); - runner.resolve("Initial pending lane reply"); - const source = await sourcePromise; - const guidance = await guidancePromise; - assert.equal(source.aiReply.status, "pending"); - assert.equal(source.aiReply.authorId, "ling_agent"); - assert.equal(guidance.aiReply, undefined); - assert.equal(guidance.replyDispatch.disposition, "followup_queued"); - assert.equal(guidance.replyDispatch.targetTeammateId, "ling_agent"); - assert.equal(guidance.replyDispatch.intervention.mode, "queue_followup"); - assert.equal(guidance.replyDispatch.intervention.messagePayload.sourceMessageId, guidance.message.id); - assert.equal(runner.runs.length, 1); - assert.equal(runner.abortCount, 0); - - await waitForCondition(() => runner.runs.length === 2, "pending lane continuation to start"); - assert.equal(readCurrentChannelUserMessage(runner.runs[1].task.prompt), guidance.message.body); - runner.resolve("Pending lane guidance reply"); - await waitForCondition(() => store.getRunIntervention(guidance.replyDispatch.intervention.id)?.completedAt, "pending lane guidance to complete"); - assert.equal(runner.abortCount, 0); - } finally { - releaseWebSearch?.(); - for (let index = 0; index < 4; index += 1) { - runner.resolve("cleanup"); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel messages persist and AI blocker replies do not drop the user message", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-messages-")); - const daemon = await startDaemonForTest(); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultTeammateId: "engineer", - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "请 @Engineer 完善频道需求", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - assert.equal(created.message.body, "请 @Engineer 完善频道需求"); - assert.equal(created.aiReply.status, "blocked"); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); - assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("known unavailable teammate is accepted and replayed as one typed Channel blocker", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const teammateId = "teammate_builtin_engineer"; - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - status: "paused", - }); - const body = { - body: "@Engineer 请执行这个任务", - mentions: [], - requestAiReply: true, - idempotencyKey: "unavailable-participant-replay", - }; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, body); - assert.equal(created.message.mentions[0].id, teammateId); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, `participant_unavailable_in_channel:${teammateId}`); - assert.match(created.aiReply.body, /当前无法在此频道参与/); - - const replay = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, body); - assert.equal(replay.message.id, created.message.id); - assert.equal(replay.aiReply.id, created.aiReply.id); - assert.equal(runner.runs.length, 0); - - const store = stores.get(cwd); - assert.ok(store); - const sourceItem = store.getConversationItem(created.message.id); - assert.ok(sourceItem?.turnId); - const dispatch = store.findWorkUnitByIdempotency({ - kind: "channel_turn_dispatch", - ownerType: "ling_turn", - ownerId: sourceItem.turnId, - idempotencyKey: `channel-turn-dispatch:${sourceItem.turnId}`, - }); - assert.deepEqual(dispatch.request.unavailableRequestedParticipantIds, [teammateId]); - assert.deepEqual(sourceItem.payload.mentions.map((mention) => mention.id), [teammateId]); - assert.equal(dispatch.status, "succeeded"); - assert.equal(store.getLingTurn(sourceItem.turnId).status, "blocked"); - const turnTransitions = await getJson( - `${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=turn&entityId=${encodeURIComponent(sourceItem.turnId)}`, - ); - assert.equal(turnTransitions.transitions.every((transition) => transition.entityType === "turn"), true); - assert.equal(turnTransitions.transitions.every((transition) => transition.entityId === sourceItem.turnId), true); - assert.equal(turnTransitions.transitions.some((transition) => transition.toStatus === "blocked"), true); - assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) - .filter((item) => item.kind === "assistant_message").length, 1); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("unavailable participant produces the same typed blocker over Channel SSE", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-unavailable-participant-sse-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const teammateId = "teammate_builtin_engineer"; - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - hidden: true, - }); - const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: "@Engineer 请检查这个任务", - mentions: [], - requestAiReply: true, - }), - }); - assert.equal(response.status, 200); - const events = parseSseEvents(await response.text()); - assert.deepEqual(events.map((event) => event.event), ["ready", "blocked"]); - assert.equal(events.at(-1).data.aiReply.status, "blocked"); - assert.equal(events.at(-1).data.aiReply.errorMessage, `participant_unavailable_in_channel:${teammateId}`); - assert.deepEqual(events[0].data.message.mentions.map((mention) => mention.id), [teammateId]); - assert.equal(runner.runs.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel discussion uses the mentioned teammate provider preset", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-teammate-provider-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - const check = await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - assert.equal(check.ok, true); - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer 你好", - mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, teammateId); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Designer reply"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); - assert.match(runner.runs[0].task.prompt, /Response language: follow_user \(source=default\)/); - assert.match(runner.runs[0].task.prompt, /Current teammate: Designer \(id: teammate_builtin_designer, role: designer\)/); - assert.doesNotMatch(runner.runs[0].task.prompt, /Channel teammates \(configured roster/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("an untrusted scripted channel adapter cannot publish Provider availability incidents", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-provider-runtime-incident-")); - const rateLimitError = Object.assign(new Error("Too many requests"), { code: "rate_limited" }); - const runner = new ScriptedRunner([rateLimitError]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); - const store = stores.get(cwd); - assert.ok(store); - const availability = store.listAvailabilityIncidents({ - providerPresetId: provider.id, - now: new Date().toISOString(), - limit: 10, - }); - assert.deepEqual(availability, [], "adapter error text is not an admitted SDK Provider failure fact"); - assert.deepEqual(store.listReliabilityIncidents({ agentId: teammateId, capabilityScope: "code", limit: 10 }), []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel observer bus stays inert when the feature flag is disabled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-off-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { teamObserverBus: false } }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready", created.message.threadId); - const store = stores.get(cwd); - assert.ok(store); - assert.deepEqual(store.listChannelTeamEvidenceItems({ channelId: channel.id, limit: 10 }), []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel observer bus writes bounded relevance evidence without visible observer messages", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-on-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { teamObserverBus: true } }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); - const store = stores.get(cwd); - assert.ok(store); - const evidence = store.listChannelTeamEvidenceItems({ - channelId: channel.id, - turnId: created.message.id, - kind: "agent_relevance_card", - limit: 10, - }); - assert.equal(evidence.length, 1); - assert.equal(evidence[0].agentId, teammateId); - assert.equal(evidence[0].visibility, "internal"); - assert.equal(evidence[0].status, "completed"); - assert.equal(evidence[0].sourceRefs.some((sourceRef) => sourceRef.type === "message" && sourceRef.id === created.message.id), true); - assert.match(evidence[0].summary, /Engineer/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel observer bus skips unavailable observers without blocking the main reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-unavailable-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { teamObserverBus: true } }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - store.createAvailabilityIncident( - { - id: "incident_observer_engineer_overloaded", - agentId: teammateId, - providerPresetId: provider.id, - state: "overloaded", - kind: "overload", - severity: "medium", - summary: "Observer provider is temporarily overloaded.", - observedAt: "2099-06-27T00:00:00.000Z", - expiresAt: "2099-06-28T00:10:00.000Z", - }, - "2099-06-27T00:00:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - assert.deepEqual(listed.messages.map((message) => message.id), [created.message.id, created.aiReply.id]); - assert.deepEqual( - store.listChannelTeamEvidenceItems({ - channelId: channel.id, - turnId: created.message.id, - kind: "agent_relevance_card", - limit: 10, - }), - [], - ); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel observer bus applies provider-level availability incidents to matching observers", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-unavailable-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { teamObserverBus: true } }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - store.createAvailabilityIncident( - { - id: "incident_provider_rate_limited", - providerPresetId: provider.id, - state: "rate_limited", - kind: "quota", - severity: "medium", - summary: "Provider quota is temporarily exhausted.", - observedAt: "2099-06-27T00:00:00.000Z", - expiresAt: "2099-06-28T00:10:00.000Z", - }, - "2099-06-27T00:00:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - assert.deepEqual( - store.listChannelTeamEvidenceItems({ - channelId: channel.id, - turnId: created.message.id, - kind: "agent_relevance_card", - limit: 10, - }), - [], - ); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel observer bus ignores expired provider-level availability incidents", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-observer-bus-provider-expired-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { teamObserverBus: true } }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - store.createAvailabilityIncident( - { - id: "incident_provider_expired_outage", - providerPresetId: provider.id, - state: "offline", - kind: "network", - severity: "medium", - summary: "Provider network outage already expired.", - observedAt: "2026-06-26T00:00:00.000Z", - expiresAt: "2026-06-26T00:10:00.000Z", - }, - "2026-06-26T00:00:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 看一下这个实现边界", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const evidence = store.listChannelTeamEvidenceItems({ - channelId: channel.id, - turnId: created.message.id, - kind: "agent_relevance_card", - limit: 10, - }); - assert.equal(evidence.length, 1); - assert.equal(evidence[0].agentId, teammateId); - assert.equal(evidence[0].status, "completed"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel discussion injects web search context before runner reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-web-search-context-")); - const runner = new RecordingRunner(); - const webSearchCalls = []; - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - secretResolver: createCompositeSecretResolver({ env: { OPENAI_API_KEY: "sk-test-secret-value" } }), - channelWebFetch: async (url, init) => { - webSearchCalls.push({ url: String(url), init }); - return new Response(JSON.stringify({ - output_text: "Fresh web evidence summary.", - output: [ - { - type: "web_search_call", - action: { - type: "search", - sources: [ - { type: "url", url: "https://example.com/fresh", title: "Fresh source" }, - ], - }, - }, - ], - }), { status: 200, headers: { "content-type": "application/json" } }); - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - kind: "openai", - displayName: "OpenAI Responses + Web Search", - providerId: "openai", - model: "gpt-5.5", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - apiKeyRef: "env:OPENAI_API_KEY", - authHeader: true, - capabilities: ["chat", "tool_call", "reasoning", "web_search"], - })).provider; - const store = stores.get(cwd); - assert.ok(store); - store.recordProviderCheckResult(provider.id, { - ok: true, - code: "static_check", - message: "Provider ready for web search context test.", - checkedAt: new Date().toISOString(), - }); - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer 查一下 Ling Stack 最新消息", - mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Designer reply"); - assert.equal(finalReply.webEvidence.status, "available"); - assert.equal(finalReply.webEvidence.query, "查一下 Ling Stack 最新消息"); - assert.equal(finalReply.webEvidence.provider.adapterId, "openai-responses-web-search"); - assert.deepEqual(finalReply.webEvidence.sources.map((source) => source.url), ["https://example.com/fresh"]); - assert.doesNotMatch(JSON.stringify(finalReply.webEvidence), /sk-test-secret-value/); - const workUnit = store.listWorkUnits({ kind: "channel_ai_reply", linkedMessageId: finalReply.id, limit: 1 })[0]; - assert.ok(workUnit); - assert.ok(finalReply.linkedRunId); - const run = store.getRun(finalReply.linkedRunId); - assert.ok(run); - assert.equal(run.status, "succeeded"); - assert.equal(run.workUnitId, workUnit.id); - assert.equal(workUnit.linkedRunId, run.id); - assert.equal(workUnit.linkedMessageId, finalReply.id); - assert.equal(store.getLingThread(run.threadId)?.kind, "channel_discussion"); - assert.equal(store.listWorkUnits({ kind: "channel_ai_reply", linkedMessageId: finalReply.id, limit: 10 }).length, 1); - const webEvidenceEvents = store.listRuntimeEvents(workUnit.id).filter((event) => event.type === "web_evidence_recorded"); - assert.equal(webEvidenceEvents.length, 1); - assert.equal(webEvidenceEvents[0].data.toolSurface, "web"); - assert.equal(webEvidenceEvents[0].data.sourceCount, 1); - assert.deepEqual(webEvidenceEvents[0].data.sources.map((source) => source.url), ["https://example.com/fresh"]); - const webProgressEvents = store.listRuntimeEvents(workUnit.id).filter((event) => event.type === "work_unit_progress" && event.data.toolSurface === "web"); - assert.deepEqual(webProgressEvents.map((event) => event.data.type), [ - "web_search_planned", - "web_search_started", - "web_search_completed", - ]); - assert.equal(webProgressEvents[2].data.status, "available"); - assert.equal(webProgressEvents[2].data.sourceCount, 1); - assert.equal(webSearchCalls.length, 1); - assert.equal(webSearchCalls[0].url, "https://api.openai.com/v1/responses"); - const requestBody = JSON.parse(webSearchCalls[0].init.body); - assert.deepEqual(requestBody.tools, [{ type: "web_search", search_context_size: "low" }]); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Ling web context:/); - assert.match(runner.runs[0].task.prompt, /Fresh web evidence summary/); - assert.match(runner.runs[0].task.prompt, /https:\/\/example\.com\/fresh/); - assert.match(runner.runs[0].systemPrompt, /explain the limitation briefly in user-facing language/); - assert.match(runner.runs[0].systemPrompt, /do not repeat provider, relay, or protocol diagnostics verbatim/); - assert.doesNotMatch(runner.runs[0].task.prompt, /sk-test-secret-value/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel discussion blocks runner prompt envelope echoes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-prompt-echo-")); - const runner = new PromptEchoRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer 给我一片关于AI发展的分析报告", - mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.match(finalReply.body, /Runner echoed internal channel context/); - assert.doesNotMatch(finalReply.body, /Channel collaboration protocol:/); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel message stream emits visible deltas into a durable AI reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-")); - const runner = new ScriptedRunner(["Streamed channel reply"]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: "@Engineer 请流式回复", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }), - }); - - assert.equal(response.status, 200); - const events = parseSseEvents(await response.text()); - assert.deepEqual(events.map((event) => event.event), ["ready", "reply_pending", "ready", "run_linked", "delta", "done"]); - assert.equal(events[0].data.message.body, "@Engineer 请流式回复"); - assert.equal(events[1].data.sourceItemId, events[0].data.message.id); - assert.equal(events[1].data.targetTeammateId, "teammate_builtin_engineer"); - assert.ok(events[1].data.startedAt); - assert.equal(events[2].data.aiReply.status, "pending"); - assert.equal(events[3].data.itemId, events[2].data.aiReply.id); - assert.ok(events[3].data.linkedRunId); - const { threadId, sourceMessageId, ...activeReplyWithoutLineage } = events[3].data.activeReply; - assert.deepEqual(activeReplyWithoutLineage, { - runId: events[3].data.linkedRunId, - sourceRunId: events[3].data.linkedRunId, - workUnitId: events[3].data.activeReply.workUnitId, - channelId: channel.id, - teammateId: "teammate_builtin_engineer", - status: "queued", - createdAt: events[3].data.activeReply.createdAt, - updatedAt: events[3].data.activeReply.updatedAt, - }); - assert.match(threadId, /^thread_/); - assert.equal(sourceMessageId, events[0].data.message.id); - assert.equal(threadId, events[2].data.aiReply.threadId); - assert.ok(events[3].data.activeReply.workUnitId); - assert.equal(JSON.stringify(events[3].data.activeReply).includes("prompt"), false); - assert.equal(events[4].data.delta, "Streamed channel reply"); - assert.equal(events[4].data.body, "Streamed channel reply"); - assert.equal(events[5].data.aiReply.body, "Streamed channel reply"); - - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - const finalReply = listed.messages.find((message) => message.id === events[2].data.aiReply.id); - assert.equal(finalReply.body, "Streamed channel reply"); - assert.equal(finalReply.status, "ready"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("conversation stream materializes uploaded attachments into the run prompt", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-conversation-attachment-")); - const runner = new ScriptedRunner(["Attachment received"]); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { - lingConversationTone: "friendly", - }); - const attachment = (await postJson(`${daemon.url}/conversations/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "notes.txt", - contentBase64: Buffer.from("Alpha decision: keep the shared composer contract.").toString("base64"), - })).attachment; - - const response = await fetch(`${daemon.url}/conversations/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "Summarize the attached note.", - attachmentIds: [attachment.id], - providerPresetId: provider.id, - authorizationMode: "smart", - workspaceAccessMode: "workspace_write", - }), - }); - - assert.equal(response.status, 200); - const events = parseSseEvents(await response.text()); - const initialReady = events.find((event) => event.event === "ready" && event.data.userMessage); - assert.ok(initialReady); - assert.equal(initialReady.data.userMessage.attachments[0].id, attachment.id); - assert.equal(initialReady.data.userMessage.attachments[0].fileName, "notes.txt"); - const linkedReady = events.find((event) => event.event === "ready" && event.data.activeReply); - assert.ok(linkedReady); - assert.equal(linkedReady.data.activeReply.runId, linkedReady.data.runId); - assert.equal(linkedReady.data.activeReply.sourceRunId, linkedReady.data.runId); - assert.equal(linkedReady.data.activeReply.threadId, linkedReady.data.thread.id); - assert.equal(linkedReady.data.activeReply.teammateId, "ling_agent"); - assert.equal(linkedReady.data.activeReply.status, "queued"); - assert.ok(linkedReady.data.activeReply.workUnitId); - assert.equal(JSON.stringify(linkedReady.data.activeReply).includes("requestSnapshot"), false); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Attached files:/); - assert.match(runner.runs[0].task.prompt, /Alpha decision: keep the shared composer contract\./); - assert.doesNotMatch(runner.runs[0].task.prompt, /Use English for user-visible output/); - assert.ok(runner.runs[0].appendSystemPrompt.some((instruction) => /Default conversation tone: friendly\./.test(instruction))); - assert.ok(runner.runs[0].appendSystemPrompt.some((instruction) => /Match the language of the most recent user message/.test(instruction))); - - const done = events.find((event) => event.event === "done"); - assert.ok(done); - assert.equal(done.data.run.requestSnapshot.lingConversationTone, "friendly"); - assert.equal(done.data.run.requestSnapshot.authorizationMode, "smart"); - assert.equal(done.data.run.requestSnapshot.workspaceAccessMode, "workspace_write"); - assert.equal(done.data.run.requestSnapshot.responseLanguage, "en-US"); - // System instructions are retained in the Runner input above but are - // intentionally omitted from the public Run snapshot. - assert.equal(done.data.run.requestSnapshot.appendSystemPrompt, undefined); - - const detail = await getJson(`${daemon.url}/conversations/${encodeURIComponent(initialReady.data.thread.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(detail.defaults.authorizationMode, "smart"); - assert.equal(detail.defaults.workspaceAccessMode, "workspace_write"); - const userMessage = detail.messages.find((message) => message.id === initialReady.data.userMessage.id); - assert.equal(userMessage.attachments[0].id, attachment.id); - assert.deepEqual(detail.executionActions, []); - - const preview = await getJson(`${daemon.url}/attachments/${encodeURIComponent(attachment.id)}/preview?cwd=${encodeURIComponent(cwd)}&scope=conversation&threadId=${encodeURIComponent(initialReady.data.thread.id)}`); - assert.equal(preview.kind, "plain_text"); - assert.match(preview.text, /Alpha decision/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel message stream hides unbound task proposal blocks without creating approvals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-message-stream-proposal-")); - const runner = new ScriptedRunner([ - `可以开始。\n{"title":"Stream proposal","prompt":"Implement streaming","assigneeTeammateId":"teammate_builtin_engineer","acceptanceCriteria":["reply streams"],"collaborationSteps":[{"teammateId":"teammate_builtin_engineer","action":"coordinated","summary":"Implement"}],"requiredTools":["read"],"riskSummary":"low"}`, - ]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const response = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: "@Engineer 可以开始实现吗", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }), - }); - - assert.equal(response.status, 200); - const events = parseSseEvents(await response.text()); - assert.deepEqual(events.map((event) => event.event), ["ready", "reply_pending", "ready", "run_linked", "delta", "done"]); - assert.doesNotMatch(JSON.stringify(events.map((event) => event.data)), /ling_task_proposal/); - assert.equal(events[4].data.body, "可以开始。\n"); - assert.equal(events[5].data.aiReply.body, "可以开始。"); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); - assert.equal(proposals.proposals.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel AI reply prompt carries projected segment context for short follow-ups", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-followup-context-")); - const runner = new ScriptedRunner([ - "雨下到第三天,城里的钟停了。修表匠阿岚把怀表放进钟芯,世界重新开始走动。", - "四百字版本", - ]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "300字的小说", - providerPresetId: provider.id, - teammateId: "ling_agent", - requestAiReply: true, - }); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, first.aiReply.id, "ready"); - - const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "400字", - providerPresetId: provider.id, - teammateId: "ling_agent", - requestAiReply: true, - }); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, second.aiReply.id, "ready"); - - assert.equal(runner.runs.length, 2); - assert.equal(runner.segmentationRuns.length, 1); - assert.match(runner.runs[1].task.prompt, /Conversation context selected by Continuity v2/); - assert.match(runner.runs[1].task.prompt, /300字的小说/); - assert.match(runner.runs[1].task.prompt, /雨下到第三天,城里的钟停了/); - assert.match(runner.runs[1].task.prompt, /User message:[\s\S]*400字/); - assert.doesNotMatch(runner.runs[1].task.prompt, /AI teammate is preparing a reply/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel AI reply injects Plan Mode instructions for plan threads", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-plan-mode-prompt-")); - const runner = new ScriptedRunner(["1. Inspect first."]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const thread = await postJson(`${daemon.url}/threads?cwd=${encodeURIComponent(cwd)}`, { - kind: "channel_discussion", - channelId: channel.id, - }); - await patchJson(`${daemon.url}/threads/${encodeURIComponent(thread.thread.id)}/mode?cwd=${encodeURIComponent(cwd)}`, { - mode: "plan", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Plan the next implementation slice.", - surface: { kind: "thread", threadId: thread.thread.id }, - providerPresetId: provider.id, - teammateId: "ling_agent", - requestAiReply: true, - }); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready", created.message.threadId); - - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].systemPrompt, /Plan Mode is active/); - assert.match(runner.runs[0].systemPrompt, //); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel no-tools AI reply retries once with compacted channel summary after context-length error", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reply-context-retry-")); - const runner = new ScriptedRunner([ - new Error("context_length exceeded by test provider"), - "Retry reply", - ]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer 请根据频道上下文回复", - mentions: [{ id: "engineer", label: "Engineer", kind: "ai-teammate" }], - requestAiReply: true, - }); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const reply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(reply.body, "Retry reply"); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[1].task.prompt, /Compacted channel discussion summary for a no-tools retry/); - assert.match(runner.runs[1].task.prompt, /channel_discussion_summary|Compacted visible message count/i); - const downgradedBudget = resolveProviderContextBudget(provider, "channel_reply"); - assert.notEqual(downgradedBudget.capabilitySource, "runtime_observed", - "scripted error text permits a bounded reply retry, not Provider window learning"); - assert.equal(downgradedBudget.contextWindowTokens, 32768); - const providerList = await getJson(`${daemon.url}/providers?cwd=${encodeURIComponent(cwd)}`); - const persistedProvider = providerList.providers.find((item) => item.id === provider.id); - assert.ok(persistedProvider); - assert.equal(persistedProvider.contextWindow, undefined); - assert.equal(persistedProvider.maxTokens, undefined); - - const summaries = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`); - assert.equal(summaries.contextSummaries.length, 0); - const store = stores.get(cwd); - assert.ok(store); - const incidents = store.listAvailabilityIncidents({ - providerPresetId: provider.id, - now: new Date().toISOString(), - limit: 10, - }); - assert.deepEqual(incidents, [], "only the trusted SDK admission seam can publish runtime availability"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel hard summary uses the effective workspace provider before dispatching a reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-proactive-summary-")); - const runner = new SummaryAwareRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - contextWindow: 1_800, - maxTokens: 512, - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - const thread = store.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: channel.id, - title: "Proactive summary", - objective: "Summarize before the provider rejects the prompt", - }, "2026-08-10T06:00:00.000Z").thread; - - for (let index = 0; index < 10; index += 1) { - const createdAt = new Date(Date.parse("2026-08-10T06:00:01.000Z") + index * 10_000).toISOString(); - const turn = store.appendConversationUserTurn({ - itemId: `item_proactive_summary_${index}`, - turnId: `turn_proactive_summary_${index}`, - channelId: channel.id, - threadId: thread.id, - title: `Historical fact ${index}`, - userInstruction: `Historical fact ${index}`, - payload: { - body: `Historical fact ${index}: ${"x".repeat(900)}`, - format: "plain_text", - mentions: [], - attachmentIds: [], - linkPreviewIds: [], - fileRefs: [], - }, - idempotencyKey: `proactive-summary:${index}`, - }, createdAt); - const run = store.createRun({ - id: `run_proactive_summary_${index}`, - cwd, - taskId: `task_proactive_summary_${index}`, - channelId: channel.id, - threadId: thread.id, - turnId: turn.turnId, - providerName: "test", - providerModel: "test", - taskSnapshot: { - id: `task_proactive_summary_${index}`, - title: `Historical fact ${index}`, - prompt: `Historical fact ${index}`, - }, - providerSnapshot: { provider: "test", model: "test" }, - requestSnapshot: { - cwd, - task: { - id: `task_proactive_summary_${index}`, - title: `Historical fact ${index}`, - prompt: `Historical fact ${index}`, - }, - }, - createdAt: new Date(Date.parse(createdAt) + 1_000).toISOString(), - }); - store.markRunStarted(run.id, new Date(Date.parse(createdAt) + 2_000).toISOString()); - store.markRunSucceeded(run.id, new Date(Date.parse(createdAt) + 3_000).toISOString()); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer continue from the durable summary.", - mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], - teammateId: "teammate_builtin_engineer", - requestAiReply: true, - }); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - - const target = resolveChannelReplyExecutionTarget({ - store, - channel, - requestedTeammateId: "teammate_builtin_engineer", - }); - assert.equal(target.providerPresetId, provider.id); - const currentItem = store.getConversationItem(created.message.id); - assert.ok(currentItem); - const plan = planConversationContextSummary({ - store, - sourceItemId: currentItem.id, - inputBudgetTokens: resolveProviderContextBudget(provider, "channel_reply").inputBudgetTokens, - }); - assert.equal(plan?.trigger, "hard"); - assert.equal(runner.summaryRuns.length, 1); - assert.equal(runner.replyRuns.length, 1); - const summaries = store.listConversationContextSummaries({ threadId: thread.id }); - assert.equal(summaries.length, 1); - assert.equal(summaries[0].objective, "Preserve the durable historical objective."); - assert.ok(summaries[0].coveredThroughSequence < currentItem.sequence); - assert.match(runner.replyRuns[0].task.prompt, /Preserve the durable historical objective/); - const dispatch = store.findWorkUnitByIdempotency({ - kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: currentItem.turnId, - idempotencyKey: `channel-turn-dispatch:${currentItem.turnId}`, - }); - assert.ok(dispatch); - const summaryEvents = store.listRuntimeEvents(dispatch.id) - .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); - assert.deepEqual(summaryEvents.map((event) => event.data.status), ["completed"]); - assert.equal(summaryEvents[0].attemptId, dispatch.activeAttemptId); - assert.equal(summaryEvents[0].data.sourceItemId, currentItem.id); - - runner.failSummary = true; - for (let index = 10; index < 20; index += 1) { - const createdAt = new Date(Date.parse("2026-08-10T06:02:00.000Z") + index * 10_000).toISOString(); - const turn = store.appendConversationUserTurn({ - itemId: `item_proactive_summary_${index}`, - turnId: `turn_proactive_summary_${index}`, - channelId: channel.id, - threadId: thread.id, - title: `Historical fact ${index}`, - userInstruction: `Historical fact ${index}`, - payload: { - body: `Historical fact ${index}: ${"x".repeat(900)}`, - format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [], - }, - idempotencyKey: `proactive-summary:${index}`, - }, createdAt); - const run = store.createRun({ - id: `run_proactive_summary_${index}`, - cwd, - taskId: `task_proactive_summary_${index}`, - channelId: channel.id, - threadId: thread.id, - turnId: turn.turnId, - providerName: "test", - providerModel: "test", - taskSnapshot: { id: `task_proactive_summary_${index}`, title: `Historical fact ${index}`, prompt: `Historical fact ${index}` }, - providerSnapshot: { provider: "test", model: "test" }, - requestSnapshot: { cwd, task: { id: `task_proactive_summary_${index}`, title: `Historical fact ${index}`, prompt: `Historical fact ${index}` } }, - createdAt: new Date(Date.parse(createdAt) + 1_000).toISOString(), - }); - store.markRunStarted(run.id, new Date(Date.parse(createdAt) + 2_000).toISOString()); - store.markRunSucceeded(run.id, new Date(Date.parse(createdAt) + 3_000).toISOString()); - } - const failedSummaryTurn = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer continue with the retained facts after summary failure.", - mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], - teammateId: "teammate_builtin_engineer", - requestAiReply: true, - }); - const failedSource = store.getConversationItem(failedSummaryTurn.message.id); - assert.ok(failedSource); - const failedDispatch = store.findWorkUnitByIdempotency({ - kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: failedSource.turnId, - idempotencyKey: `channel-turn-dispatch:${failedSource.turnId}`, - }); - assert.ok(failedDispatch); - const failedEvents = store.listRuntimeEvents(failedDispatch.id) - .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); - assert.deepEqual(failedEvents.map((event) => event.data.status), ["generation_failed"]); - assert.equal(failedEvents[0].data.fallback, "prepared_input_budget_gate"); - assert.equal(store.listConversationContextSummaries({ threadId: thread.id }).length, 1); - const listedFallback = await waitForChannelMessageStatus( - daemon.url, cwd, channel.id, failedSummaryTurn.aiReply.id, "ready", failedSummaryTurn.message.threadId, - ); - const fallbackReply = listedFallback.messages.find((message) => message.id === failedSummaryTurn.aiReply.id); - assert.equal(fallbackReply.body, "Reply after proactive summary."); - assert.equal(runner.replyRuns.length, 2); - const fallbackRun = runner.replyRuns[1]; - const preparedTokens = estimateTextTokens(fallbackRun.systemPrompt) + estimateTextTokens(fallbackRun.task.prompt); - assert.ok(preparedTokens <= resolveProviderContextBudget(provider, "channel_reply").inputBudgetTokens); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel hard summary with no completed prefix records the limitation and blocks oversized input", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-no-summary-prefix-")); - const runner = new SummaryAwareRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - contextWindow: 1_800, maxTokens: 512, - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await setWorkspaceDefaultProvider(daemon.url, cwd, provider.id); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `@Engineer analyze this full input: ${"x".repeat(12_000)}`, - mentions: [{ id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }], - teammateId: "teammate_builtin_engineer", - providerPresetId: provider.id, - requestAiReply: true, - }); - const listed = await waitForChannelMessageStatus( - daemon.url, cwd, channel.id, created.aiReply.id, "blocked", created.message.threadId, - ); - assert.match(listed.messages.find((message) => message.id === created.aiReply.id).body, /context budget/u); - const store = stores.get(cwd); - const source = store.getConversationItem(created.message.id); - const dispatch = store.findWorkUnitByIdempotency({ - kind: "channel_turn_dispatch", ownerType: "ling_turn", ownerId: source.turnId, - idempotencyKey: `channel-turn-dispatch:${source.turnId}`, - }); - assert.ok(dispatch); - const events = store.listRuntimeEvents(dispatch.id) - .filter((event) => event.type === "work_unit_progress" && event.data.stage === "conversation_hard_summary"); - assert.deepEqual(events.map((event) => event.data.status), ["no_eligible_prefix"]); - assert.equal(events[0].data.fallback, "prepared_input_budget_gate"); - assert.equal(runner.runs.length, 0); - assert.equal(store.listRuns({ limit: 10 }).length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("mentions_only creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentions-only-route-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - simulateLegacyChannelWithoutModel(cwd, channel.id); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个频道自动回复怎么设计?", - }); - - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); - const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(blockedReply.body, "AI teammate is not configured for this channel."); - assert.equal(blockedReply.errorMessage, "No provider preset is selected for this channel discussion."); - assert.equal(runner.runs.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].mode, "mentions_only"); - assert.equal(decisions.decisions[0].action, "reply"); - assert.equal(decisions.decisions[0].status, "failed"); - assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("default_teammate routes root messages through Ling Agent without proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-route-")); - const runner = new ScriptedRunner([ - `{"title":"Should not persist","prompt":"Create a task","acceptanceCriteria":["never"],"requiredTools":["read"],"riskSummary":"blocked by proposalMode"}Visible default reply`, - "这个频道当前有多个 AI 队友,具体数量以频道成员列表为准。", - ]); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个实现边界怎么处理?", - }); - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Visible default reply"); - assert.doesNotMatch(finalReply.body, /ling_task_proposal/); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].systemPrompt, /Do not include hidden proposal blocks/); - assert.match(runner.runs[0].task.prompt, /Current teammate: Ling Agent \(id: ling_agent, role: custom\)/); - assert.doesNotMatch(runner.runs[0].task.prompt, /Channel teammates \(configured roster/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].mode, "default_teammate"); - assert.equal(decisions.decisions[0].action, "reply"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.equal(decisions.decisions[0].selectedTeammateId, "ling_agent"); - assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual(proposals.proposals, []); - assert.deepEqual((await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks, []); - const firstReplyRuns = (await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`)).runs; - assert.equal(firstReplyRuns.length, 1); - assert.equal(firstReplyRuns[0].id, finalReply.linkedRunId); - assert.equal(firstReplyRuns[0].status, "succeeded"); - assert.equal(firstReplyRuns[0].taskId, created.message.id); - - const countQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "现在频道有多少个队友", - }); - assert.equal(countQuestion.aiReply.status, "pending"); - const countReplyList = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, countQuestion.aiReply.id, "ready"); - const countReply = countReplyList.messages.find((message) => message.id === countQuestion.aiReply.id); - assert.equal(countReply.body, "这个频道当前有多个 AI 队友,具体数量以频道成员列表为准。"); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[1].task.prompt, /Channel teammates \(configured roster; not online\/presence\):/); - assert.match(runner.runs[1].task.prompt, /Engineer \(id: teammate_builtin_engineer, role: engineer\).*default/); - assert.match(runner.runs[1].task.prompt, /Do not claim online\/presence status/); - - const countDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(countQuestion.message.id)}`); - assert.equal(countDecisions.decisions.length, 1); - assert.equal(countDecisions.decisions[0].mode, "default_teammate"); - assert.equal(countDecisions.decisions[0].action, "reply"); - assert.equal(countDecisions.decisions[0].status, "accepted"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector creates visible multi-replies for discussion-only all-teammate requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-visible-multi-reply-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "所有队友都回复我一下", - }); - - assert.equal(created.message.body, "所有队友都回复我一下"); - assert.equal(created.aiReplies.length, 3); - assert.equal(created.aiReply.id, created.aiReplies[0].id); - assert.deepEqual( - created.aiReplies.map((reply) => reply.authorId).sort(), - ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"].sort(), - ); - assert.deepEqual(created.aiReplies.map((reply) => reply.status), ["pending", "pending", "pending"]); - - await Promise.all(created.aiReplies.map((reply) => waitForChannelMessageStatus(daemon.url, cwd, channel.id, reply.id, "ready"))); - assert.equal(runner.runs.length, 3); - assert(runner.runs.every((run) => /Do not include hidden proposal blocks/.test(run.systemPrompt))); - assert(runner.runs.every((run) => /Channel teammates \(configured roster; not online\/presence\):/.test(run.task.prompt))); - assert(runner.runs.every((run) => /Visible multi-reply participants for this batch:/.test(run.task.prompt))); - assert(runner.runs.every((run) => /Architect \(id: teammate_builtin_architect, role: architect\)/.test(run.task.prompt))); - assert(runner.runs.every((run) => /Engineer \(id: teammate_builtin_engineer, role: engineer\)/.test(run.task.prompt))); - assert(runner.runs.every((run) => /QA \(id: teammate_builtin_qa, role: qa\)/.test(run.task.prompt))); - assert(runner.runs.every((run) => /Do not claim online\/presence status/.test(run.task.prompt))); - assert(runner.runs.every((run) => /not-yet-generated peer replies/.test(run.task.prompt))); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(routeDecisions.decisions.length, 0); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "visible_multi_reply"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.deepEqual( - [...decisions.decisions[0].selectedTeammateIds].sort(), - created.aiReplies.map((reply) => reply.authorId).sort(), - ); - assert.deepEqual( - [...decisions.decisions[0].resultItemIds].sort(), - created.aiReplies.map((reply) => reply.id).sort(), - ); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector creates workflow proposals for execution-shaped all-teammate requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-workflow-proposal-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "所有队友都回复我一下:Architect 先拆,Engineer 实现,QA 验证", - teammateId: "ling_agent", - requestAiReply: true, - }); - - assert.equal(created.message.body, "所有队友都回复我一下:Architect 先拆,Engineer 实现,QA 验证"); - assert.equal(created.aiReply, undefined); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].sourceMessageId, created.message.id); - assert.equal(proposals.proposals[0].status, "draft"); - assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_engineer"); - assert.deepEqual( - proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), - ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"], - ); - assert.equal(proposals.proposals[0].responseLanguage, "follow_user"); - assert.match(proposals.proposals[0].prompt, /需要协作执行的工作流请求/); - assert.match(proposals.proposals[0].acceptanceCriteria[0], /可审计的提案证据/); - assert.match(proposals.proposals[0].collaborationSteps[0].summary, /实现边界和步骤顺序/); - assert.match(proposals.proposals[0].riskSummary, /工作流提案/); - - const listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listedMessages.messages.filter((message) => message.kind === "ai_reply").length, 0); - - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(routeDecisions.decisions.length, 0); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "workflow_proposal"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); - assert.match(decisions.decisions[0].reason, /Workflow proposal created from channel intent/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("multi-participant concrete file workflow preserves operation-derived tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-multi-participant-file-tools-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Concrete multi-agent execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const participants = ["teammate_builtin_engineer", "teammate_builtin_qa"]; - for (const teammateId of participants) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer @QA 请协作完成一个最小可验证任务:由 Engineer 创建 r5-channel-proof.txt,内容为 continuity-v2-r5-proof;QA 执行命令验证文件内容。Engineer 与 QA 都必须完成并提供真实 evidence 后才报告成功。", - mentions: [ - { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, - { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, - ], - requestAiReply: true, - }); - assert.ok(created.autoStartedWorkflow); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - const store = stores.get(cwd); - assert.ok(store); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) - .find((candidate) => candidate.workflowId === created.autoStartedWorkflow.workflow.id); - assert.ok(decision); - assert.equal(decision.action, "execute"); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), participants); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requiredOperationIds), [["create_file"], ["verify_target"]]); - const verificationOperation = decision.execution?.operations.find((operation) => operation.id === "verify_target"); - assert.equal(verificationOperation?.commandMatchPolicy?.kind, "exact_command"); - assert.ok(verificationOperation?.commandMatchPolicy?.kind === "exact_command"); - assert.match( - verificationOperation.commandMatchPolicy.command, - new RegExp(`Buffer\\.from\\('${Buffer.from("continuity-v2-r5-proof", "utf8").toString("base64")}\\',\\s*'base64'\\)`), - ); - assert.doesNotMatch(verificationOperation.commandMatchPolicy.command, /QA/u); - - const proposal = store.listChannelExecutionProposals({ - channelId: channel.id, - sourceMessageId: created.message.id, - limit: 20, - })[0]; - assert.ok(proposal); - assert.deepEqual(proposal.requiredTools, ["write", "bash"]); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.toParticipantId), participants); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["verify_target"]]); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.requiredTools), [["write"], ["bash"]]); - const [engineerStep, qaStep] = created.autoStartedWorkflow.workflowSteps; - assert.match(engineerStep.prompt, /create_file: file_create -> r5-channel-proof\.txt/u); - assert.match(qaStep.prompt, /verify_target: run_command -> verify r5-channel-proof\.txt content/u); - assert.ok( - qaStep.prompt.includes(`exact command: ${verificationOperation.commandMatchPolicy.command}`), - "QA step prompt must carry the exact Host-materialized command used by Completion Gate", - ); - assert.match(qaStep.prompt, /do not substitute a semantically equivalent command/u); - assert.deepEqual(runner.runs[0].tools, ["write"]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("review-only participant uses an output packet while execution participant keeps Host evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-review-only-participant-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_engineer"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - } - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Review then execute", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_engineer"]) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer @Engineer 现在开始执行这个多 Agent 工作流:Designer 先评审文件结构和验收标准,Engineer 创建 review-only-proof.js 并运行 node review-only-proof.js。", - mentions: [ - { id: "teammate_builtin_designer", label: "Designer", kind: "ai-teammate" }, - { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, - ], - requestAiReply: true, - }); - - assert.ok(created.autoStartedWorkflow); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.ok(["pending", "ready"].includes(created.aiReply.status)); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); - const [designerStep, engineerStep] = created.autoStartedWorkflow.workflowSteps; - assert.equal(designerStep.toParticipantId, "teammate_builtin_designer"); - assert.equal(designerStep.metadata.delegatedRequirement.expectedOutcome, "synthesis"); - assert.deepEqual(designerStep.metadata.executionOperationIds, []); - assert.equal(engineerStep.toParticipantId, "teammate_builtin_engineer"); - assert.equal(engineerStep.metadata.delegatedRequirement.expectedOutcome, "execution"); - assert.ok(engineerStep.metadata.executionOperationIds.length > 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow Host binding rejects a semantic substitute before Completion Gate", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-exact-command-boundary-")); - const runner = new MismatchedExactCommandRunner(); - const daemon = await startDaemonForTest(runner, undefined, - withRestrictedCommandReadiness({ channels: { intentSelector: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Exact command boundary", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@QA 执行 `node app.js` 验证,并且必须提供真实 evidence。", - mentions: [{ id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }], - requestAiReply: true, - }); - assertCoordinatorWorkflowStartReply(created); - await waitForCondition(() => runner.decisions.length === 1, "workflow exact-command binding rejection"); - assert.equal(runner.decisions[0].approved, false); - assert.match(runner.decisions[0].reason, - /operation_binding_command_out_of_scope: No unique owned operation authorizes this exact command\./u); - await waitForCondition(async () => { - const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(created.autoStartedWorkflow.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - return steps.workflowSteps.every((step) => - step.status === "blocked" && step.metadata?.delegatedOutcome?.kind === "blocked"); - }, "workflow to remain blocked after Host rejection"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector does not assign specialists outside current channel", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-roster-boundary-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Architect 先拆,Engineer 实现,QA 验证", - }); - - assert.equal(created.aiReply, undefined); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); - assert.equal(proposals.proposals[0].proposedByTeammateId, "ling_agent"); - assert.deepEqual(proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), ["ling_agent"]); - assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); - assert.doesNotMatch(proposals.proposals[0].prompt, /Engineer:|QA:|Architect:/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "workflow_proposal"); - assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector uses attached AI council specialists without copying memberships", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-council-specialists-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - const developmentCouncilId = "council_builtin_development"; - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Council-backed Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_designer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - } - await putJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/council-policy?cwd=${encodeURIComponent(cwd)}`, { - attachedCouncilIds: [developmentCouncilId], - preferredCouncilId: developmentCouncilId, - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Designer 先 review 交互,QA 验证这个频道设置改动", - }); - - assert.equal(created.aiReply, undefined); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "draft"); - assert.deepEqual( - [...proposals.proposals[0].collaborationSteps.map((step) => step.teammateId)].sort(), - ["teammate_builtin_designer", "teammate_builtin_qa"].sort(), - ); - assert.equal(proposals.proposals[0].collaborationSteps.some((step) => step.teammateId === "ling_agent"), false); - const designerStep = proposals.proposals[0].collaborationSteps.find((step) => step.teammateId === "teammate_builtin_designer"); - assert.ok(designerStep); - assert.ok(designerStep.sources?.some((source) => source.kind === "council" && source.councilDisplayName === "Development Council")); - - const effective = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/effective-council?cwd=${encodeURIComponent(cwd)}`); - const designerSpecialist = effective.effectiveCouncil.specialists.find((specialist) => specialist.teammateId === "teammate_builtin_designer"); - assert.ok(designerSpecialist); - assert.ok(designerSpecialist.sources.some((source) => source.kind === "council" && source.councilId === developmentCouncilId)); - - const confirmed = await postJson(`${daemon.url}/channel-proposals/${encodeURIComponent(proposals.proposals[0].id)}/confirm-workflow?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(confirmed.proposal.status, "accepted"); - assert.deepEqual( - [...confirmed.workflowSteps.map((step) => step.toParticipantId)].sort(), - ["teammate_builtin_designer", "teammate_builtin_qa"].sort(), - ); - const designerWorkflowStep = confirmed.workflowSteps.find((step) => step.toParticipantId === "teammate_builtin_designer"); - assert.ok(designerWorkflowStep); - assert.ok(designerWorkflowStep.metadata?.proposalStepSources?.some((source) => - source.kind === "council" && - source.councilId === developmentCouncilId && - source.councilDisplayName === "Development Council" - )); - const designerChannels = await getJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_designer")}/channels?cwd=${encodeURIComponent(cwd)}`); - const qaChannels = await getJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_qa")}/channels?cwd=${encodeURIComponent(cwd)}`); - assert.equal(designerChannels.memberships.some((membership) => membership.channelId === channel.id), false); - assert.equal(qaChannels.memberships.some((membership) => membership.channelId === channel.id), false); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "workflow_proposal"); - assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector asks one clarify question for underspecified work requests", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-clarify-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep"], - })).channel; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "做一下吧", - }); - - assert.equal(created.message.body, "做一下吧"); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.equal(created.aiReply.status, "ready"); - assert.equal(created.aiReply.threadId, created.message.threadId); - assert.equal(created.aiReply.authorId, "ling_agent"); - assert.match(created.aiReply.body, /需要先确认范围/); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(routeDecisions.decisions.length, 0); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "clarify"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].createdBy, "deterministic"); - assert.deepEqual(decisions.decisions[0].selectedTeammateIds, ["ling_agent"]); - assert.deepEqual(decisions.decisions[0].resultItemIds, [created.aiReply.id]); - assert.deepEqual(decisions.decisions[0].clarification.missingFields, ["acceptance_criteria"]); - assert.equal(decisions.decisions[0].clarification.question, created.aiReply.body); - assert.match(decisions.decisions[0].reason, /Clarify question created from channel intent/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel intent selector auto-starts explicit direct workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-direct-workflow-")); - const runner = new WorkflowEvidenceRunner(); - const daemon = await startDaemonForTest(runner, {}, { channels: { terminalReports: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "开始跑 Architect -> Engineer -> QA", - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 3); - await waitForCondition( - () => runner.runs.filter((run) => !/terminal report synthesizer/i.test(run.systemPrompt ?? "")).length === 3, - "direct workflow proposal runner sequence", - ); - let listedMessages; - await waitForCondition(async () => { - listedMessages = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - return listedMessages.items.some((item) => - item.kind === "terminal_report" && item.payload?.outcomeStatus === "succeeded"); - }, "direct workflow terminal publication"); - const terminalItem = listedMessages.items.find((item) => - item.kind === "terminal_report" && item.payload?.outcomeStatus === "succeeded"); - assert.ok(terminalItem); - const terminalReport = await getJson(`${daemon.url}/terminal-reports/${encodeURIComponent(terminalItem.payload.reportId)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(terminalReport.report.outcome.status, "succeeded"); - assert.match(terminalReport.report.outcome.summary, /任务已完成/u); - assert.equal(terminalReport.report.participants.length, 3); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.equal(proposals.proposals[0].responseLanguage, "follow_user"); - assert.deepEqual( - proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), - ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"], - ); - assert.match(proposals.proposals[0].riskSummary, /运行时审批策略/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 2); - assertIntentDecisionInspectPayloadIsSafe(decisions.decisions[0]); - assert.equal(decisions.decisions[0].action, "execute"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].proposalId, proposals.proposals[0].id); - assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); - - assert.equal(created.autoStartedWorkflow.workflow.metadata.responseLanguage, "follow_user"); - assert.match(created.autoStartedWorkflow.workflowSteps[0].prompt, /Match the language of the most recent user message/); - assert.match(created.autoStartedWorkflow.workflowSteps[0].prompt, /API fields, and evidence excerpts in their original language/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("explicit response language overrides workspace default for workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-override-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { - responseLanguage: "zh-CN", - }); - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Architect 先拆,Engineer 实现,QA 验证。Please respond in English.", - }); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].responseLanguage, "en-US"); - assert.match(proposals.proposals[0].prompt, /Proposed collaboration/); - assert.match(proposals.proposals[0].acceptanceCriteria[0], /Create auditable proposal evidence/); - assert.match(proposals.proposals[0].riskSummary, /Multi-agent work starts after confirmation/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel response language overrides follow-user inference for workflow proposals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-language-channel-")); - const daemon = await startDaemonForTest(); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - responseLanguage: "en-US", - allowedTools: ["read", "grep"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Architect 先拆,Engineer 实现,QA 验证", - }); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].responseLanguage, "en-US"); - assert.match(proposals.proposals[0].prompt, /Proposed collaboration/); - assert.match(proposals.proposals[0].riskSummary, /Multi-agent work starts after confirmation/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-step file creation becomes direct workflow with bounded write tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Docs execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `在 ${cwd} 下创建 README.md`, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_docs"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].riskSummary, /write/); - assert.match(proposals.proposals[0].prompt, /权限边界/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 2); - assert.equal(decisions.decisions[0].action, "execute"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); - - assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["write"]); - assert.match(runner.runs[0].task.prompt, /Confirmed task-scoped tools:\nwrite/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("explicit command request becomes execute with bounded bash tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-command-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Command execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_qa", - autoReplyMode: "default_teammate", - allowedTools: ["read", "bash"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "运行 `pnpm test` 并报告结果。", - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); - assert.match(proposals.proposals[0].prompt, /pnpm test/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 2); - assert(decisions.decisions.every((decision) => decision.action === "execute")); - assert(decisions.decisions.every((decision) => decision.execution.operations[0].kind === "run_command")); - assert(decisions.decisions.every((decision) => decision.execution.operations[0].targetKind === "command")); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["bash"]); - assert.match(runner.runs[0].task.prompt, /pnpm test/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("explicit Ling Agent file request becomes direct workflow instead of discussion reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-mentioned-ling-file-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Mentioned Ling Agent execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `@Ling Agent 在 ${cwd} 下创建 login.html`, - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].prompt, /login\.html/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["write"]); - assert.match(runner.runs[0].task.prompt, /login\.html/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workspace follow-up can create the single file named by the previous Ling Agent reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Follow-up execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我写一个登录页面", - }, - "2026-06-12T09:00:00.000Z", - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "你可以先在工作区新建一个文件:`login.html`,然后把代码粘进去。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "可以直接在工作区帮我做好吗?", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|没有被授权执行工作区写入/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].prompt, /Target: login\.html/); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /login\.html/); - assert.match(runner.runs[0].task.prompt, /Requested objective from recent same-thread\/channel objective/); - assert.match(runner.runs[0].task.prompt, /帮我写一个登录页面/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("thread follow-up can create the single file named by the previous Ling Agent reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Thread follow-up execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我做一个打飞机小游戏", - }, - "2026-06-12T09:00:00.000Z", - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: [ - "如果确认,可以在工作区创建 `index.html`。", - "", - "```html", - "", - "", - "```", - ].join("\n"), - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "你不可以直接帮我写到里面吗?", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created, previousUser.id); - assert.notEqual(created.message.threadId, previousUser.id); - assert.equal(store.getLingThread(created.message.threadId)?.anchorItemId, previousUser.id); - assert.equal(created.aiReply.threadId, created.message.threadId); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].prompt, /Target: index\.html/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => decision.action === "execute" && /Bounded executable request/.test(decision.reason)), true); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["write"]); - assert.match(runner.runs[0].task.prompt, /Target: index\.html/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("thread follow-up opens the recent HTML target with the dedicated Host capability", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-browser-open-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - desktopBridgeToken: "deterministic-open-host-token", - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ defaultWorkspaceCwd: cwd })); - try { - await publishHostOpenAvailability(daemon.url, "deterministic-open-host-token"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Thread browser open execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我做一个打飞机小游戏", - }, - "2026-06-12T09:00:00.000Z", - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "已创建文件:`index.html`。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "可以直接帮我浏览器打开吗?", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created, previousUser.id); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.deepEqual(proposals.proposals[0].requiredTools, []); - assert.match(proposals.proposals[0].prompt, /dedicated Ling Host preview capability/); - assert.doesNotMatch(proposals.proposals[0].prompt, /Create index\.html|declared file tool scope/); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, []); - assert.deepEqual(runner.runs[0].hostCapabilityTools?.map((tool) => tool.name), ["host_open_local_target"]); - assert.match(runner.runs[0].task.prompt, /Target: index\.html/); - assert.match(runner.runs[0].task.prompt, /Do not recreate or edit the file/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted thread follow-up opens recent target from typed operation kind", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-open-")); - let previousReply; - const runner = new ScriptedRunner([ - () => JSON.stringify({ - action: "execute", - reason: "User wants to preview the recent local artifact.", - execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "open_target", kind: "open_local_target", - sourceQuote: "可以直接帮我展示一下这个成果吗?", path: "index.html", targetSourceItemId: previousReply.id })] }, - }), - "Opened local target.", - ]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - desktopBridgeToken: "typed-open-host-token", - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ defaultWorkspaceCwd: cwd, channels: { intentSelector: true } })); - try { - await publishHostOpenAvailability(daemon.url, "typed-open-host-token"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model typed local target open", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我做一个打飞机小游戏", - }, - "2026-06-12T09:00:00.000Z", - ); - previousReply = appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "已创建文件:`index.html`。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "可以直接帮我展示一下这个成果吗?", - }); - - assertCoordinatorWorkflowStartReply(created, previousUser.id); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.deepEqual(proposals.proposals[0].requiredTools, []); - assert.match(proposals.proposals[0].prompt, /open_target: open_local_target \(index\.html\)/); - assert.doesNotMatch(proposals.proposals[0].prompt, /file_create|file_edit/); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => - decision.action === "execute" && - decision.createdBy === "model_policy" && - decision.execution?.operations[0]?.kind === "open_local_target" - ), true); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); - assert.deepEqual(runner.runs[1].tools, []); - assert.deepEqual(runner.runs[1].hostCapabilityTools?.map((tool) => tool.name), ["host_open_local_target"]); - assert.match(runner.runs[1].task.prompt, /open_target: open_local_target -> index\.html/); - assert.match(runner.runs[1].task.prompt, /Execute only the operations owned by this step/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted thread follow-up writes the recent local target without phrase-rule matching", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-file-")); - let previousReply; - const content = "\n"; - const daemon = await startRealPiChannelFixture(cwd, (request) => { - if (/channel intent selector/i.test(systemText(request))) return { text: JSON.stringify({ - action: "execute", - reason: "User is asking Ling to write the previously discussed file locally.", - execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", - sourceQuote: "你现在可以帮我写在本地里面了吗?", path: "index.html", targetSourceItemId: previousReply.id, - contentSource: { kind: "literal", text: content, sourceQuote: content, sourceItemId: previousReply.id } })] }, - }) }; - if (!request.messages.some((message) => message.role === "tool")) return { tool: "write", input: { path: "index.html", content } }; - return { text: "Created local file: index.html." }; - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model thread follow-up execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = daemon.store; - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "先记一下以后可能要落地文件", - }, - "2026-06-12T09:00:00.000Z", - ); - previousReply = appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: [ - "如果确认,可以在工作区创建 `index.html`:", - "```html", - "", - "", - "```", - ].join("\n"), - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "你现在可以帮我写在本地里面了吗?", - }); - - assert.ok(created.aiReply, JSON.stringify({ created, decisions: store.listChannelIntentDecisions({ channelId: channel.id, limit: 10 }) })); - assertCoordinatorWorkflowStartReply(created, previousUser.id); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].prompt, /create_file: file_create \(index\.html\)/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(intentDecisions.decisions.some((decision) => - decision.action === "execute" && - decision.createdBy === "model_policy" && - decision.execution?.operations[0]?.kind === "file_create" - ), true); - const createOperation = intentDecisions.decisions.find((decision) => decision.createdBy === "model_policy").execution.operations[0]; - assert.equal(createOperation.binding.contentSource.kind, "literal"); - assert.equal(createOperation.binding.contentSource.text, content); - assert.deepEqual(createOperation.binding.contentSource.sourceRefs.map((ref) => ref.itemId), [previousReply.id]); - const { runs } = await daemon.assertWorkflowCompleted(created, { files: [{ path: "index.html", content }], - actionTypes: ["write"], participantIds: ["ling_agent"] }); - assert.equal(daemon.selectorRequests.length, 1); - assert.match(systemText(daemon.selectorRequests[0]), /channel intent selector/i); - assert.equal(runs.length, 1); - assert.deepEqual(runs[0].requestSnapshot.tools, ["write"]); - assert.match(runs[0].taskSnapshot.prompt, /create_file: file_create -> index\.html/); - assert.match(runs[0].taskSnapshot.prompt, /"kind":"literal"/); - assert.match(runs[0].taskSnapshot.prompt, /gameReady = true/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted create-and-run materializes ordered write and Node operations", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "execute", - reason: "User explicitly requested a file write followed by a Node verification.", - execution: { - schemaVersion: 2, - operations: [ - fileOperationCandidate({ id: "create_file", sourceQuote: "创建 app.js,内容是 console.log('ok')", path: "app.js", - contentSource: { kind: "literal", text: "console.log('ok')", sourceQuote: "console.log('ok')" } }), - commandOperationCandidate({ sourceQuote: "然后运行 node app.js 验证。", command: "node app.js", dependsOn: ["create_file"] }), - ], - }, - }), - "Created and ran app.js.", - ]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model create and run", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "write", "bash"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "创建 app.js,内容是 console.log('ok'),然后运行 node app.js 验证。", - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const store = stores.get(cwd); - assert.ok(store); - const sourceItem = store.getConversationItem(created.message.id); - assert.ok(sourceItem?.turnId); - assert.equal(created.autoStartedWorkflow.workflow.metadata.sourceTurnId, sourceItem.turnId); - const [step] = created.autoStartedWorkflow.workflowSteps; - assert.deepEqual(step.metadata.requiredTools, ["write", "bash"]); - assert.deepEqual(step.metadata.executionOperationIds, ["create_file", "run_command"]); - assert.deepEqual(step.metadata.executionPlan.operations.map((operation) => ({ - id: operation.id, - kind: operation.kind, - dependsOn: operation.dependsOn, - label: operation.targetRef?.label, - commandMatchPolicy: operation.commandMatchPolicy, - })), [ - { id: "create_file", kind: "file_create", dependsOn: [], label: "app.js", commandMatchPolicy: undefined }, - { - id: "run_command", - kind: "run_command", - dependsOn: ["create_file"], - label: "node app.js", - commandMatchPolicy: { kind: "exact_command", command: "node app.js" }, - }, - ]); - assert.deepEqual(runner.runs[1].tools, ["write", "bash"]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted multi-agent file verification binds an actual QA read when no command is supplied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-multi-agent-file-verify-")); - const daemon = await startRealPiChannelFixture(cwd, (request) => { - if (/channel intent selector/i.test(systemText(request))) return { text: JSON.stringify({ - action: "execute", - reason: "Engineer writes the file and QA verifies its literal content.", - execution: { - schemaVersion: 2, - operations: [ - fileOperationCandidate({ id: "create_file", path: "proof.txt", - sourceQuote: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", - owner: { participantId: "teammate_builtin_engineer", sourceQuote: "Engineer" }, - contentSource: { kind: "literal", text: "model-multi-agent-ok", sourceQuote: "model-multi-agent-ok" } }), - fileOperationCandidate({ id: "verify_target", kind: "file_read", path: "proof.txt", dependsOn: ["create_file"], - sourceQuote: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", - owner: { participantId: "teammate_builtin_qa", sourceQuote: "QA" } }), - ], - }, - }) }; - const isWriter = request.tools.some((tool) => tool.function.name === "write"); - const toolResult = request.messages.find((message) => message.role === "tool"); - if (!toolResult) return isWriter - ? { tool: "write", input: { path: "proof.txt", content: "model-multi-agent-ok" } } - : { tool: "read", input: { path: "proof.txt" } }; - if (!isWriter) assert.equal(messageText(toolResult), "model-multi-agent-ok", "QA must receive the actual file bytes"); - return { text: isWriter ? "Engineer completed proof.txt." : "QA read proof.txt and verified model-multi-agent-ok." }; - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model multi-agent file verification", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "write", "bash"], - })).channel; - for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Engineer @QA 请协作创建 proof.txt,内容为 model-multi-agent-ok;QA 必须在 Engineer 完成后验证文件内容。", - mentions: [ - { id: "teammate_builtin_engineer", label: "Engineer", kind: "ai-teammate" }, - { id: "teammate_builtin_qa", label: "QA", kind: "ai-teammate" }, - ], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - const store = daemon.store; - assert.ok(store); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) - .find((candidate) => candidate.action === "execute"); - assert.ok(decision); - const verificationOperation = decision.execution?.operations.find((operation) => operation.id === "verify_target"); - assert.equal(verificationOperation?.kind, "file_read"); - assert.equal(verificationOperation?.commandMatchPolicy, undefined); - assert.equal(verificationOperation?.binding.ownerParticipantId, "teammate_builtin_qa"); - assert.deepEqual(verificationOperation?.binding.inputRefs.map((ref) => ref.path), ["proof.txt"]); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["verify_target"]]); - const { runs, actions } = await daemon.assertWorkflowCompleted(created, { - files: [{ path: "proof.txt", content: "model-multi-agent-ok" }], actionTypes: ["write", "read"], - participantIds: ["teammate_builtin_engineer", "teammate_builtin_qa"], - }); - assert.deepEqual(runs.map((run) => run.requestSnapshot.tools), [["write"], ["read"]]); - assert.deepEqual(actions.map((action) => action.participantId), ["teammate_builtin_engineer", "teammate_builtin_qa"]); - assert.equal(store.getAiTeammate("teammate_builtin_qa").allowedTools.includes("write"), false); - assert.equal(runs[0].requestSnapshot.workflowOperationExecution.plan.fingerprint, runs[1].requestSnapshot.workflowOperationExecution.plan.fingerprint); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted natural-language Node work materializes executable evidence policy", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-node-executable-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "execute", - reason: "User requested a file write followed by a Node invocation.", - execution: { - schemaVersion: 2, - operations: [ - fileOperationCandidate({ id: "create_file", path: "continuity-v2-proof.js", - sourceQuote: "创建 continuity-v2-proof.js,内容是导出 formatProof", - contentSource: { kind: "generated_objective", text: "导出 formatProof", sourceQuote: "导出 formatProof", inputOperationIds: [] } }), - { ...commandOperationCandidate({ sourceQuote: "然后运行 Node 调用 formatProof(\"7319\") 验证。", dependsOn: ["create_file"] }), - commandGoal: { kind: "node_function", module: { path: "continuity-v2-proof.js", sourceQuote: "continuity-v2-proof.js" }, - invocation: 'formatProof("7319")', sourceQuote: 'formatProof("7319")' } }, - ], - }, - }), - "Created and invoked the proof function.", - ]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Natural Node invocation", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "write", "bash"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "创建 continuity-v2-proof.js,内容是导出 formatProof,然后运行 Node 调用 formatProof(\"7319\") 验证。", - }); - - assertCoordinatorWorkflowStartReply(created); - const store = stores.get(cwd); - assert.ok(store); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 10 }) - .find((candidate) => candidate.action === "execute"); - assert.ok(decision?.execution); - const commandOperation = decision.execution.operations.find((operation) => operation.id === "run_command"); - assert.equal(commandOperation?.commandMatchPolicy?.kind, "exact_command"); - assert.equal(commandOperation?.binding?.commandGoal?.modulePath, "continuity-v2-proof.js"); - assert.equal(commandOperation?.binding?.commandGoal?.invocation, 'formatProof("7319")'); - assert.deepEqual(commandOperation?.binding?.effectScope.readPaths, ["continuity-v2-proof.js"]); - assert.deepEqual(commandOperation?.dependsOn, ["create_file"]); - assert.equal(commandOperation?.targetRef?.label, 'continuity-v2-proof.js → formatProof("7319")'); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("partial existing proposal lineage becomes one durable blocker without participant reselection", async () => { - for (const scenario of [ - { - name: "single-step", - body: "创建 orphan-proof.js,内容是 console.log('proof')。", - requiredTools: ["write"], - }, - { - name: "multi-participant", - body: "请让 Engineer 和 QA 一起实现并测试这个功能。", - requiredTools: ["read"], - }, - ]) { - const cwd = await mkdtemp(resolve(tmpdir(), `ling-stack-channel-partial-proposal-${scenario.name}-`)); - const store = createSqliteRunStore(cwd); - try { - const { channel, message } = createStoreChannelAndMessage(store, cwd, scenario.body); - const proposal = store.createChannelExecutionProposal({ - channelId: channel.id, - sourceMessageId: message.id, - proposedByTeammateId: "ling_agent", - title: `Partial ${scenario.name} proposal`, - prompt: scenario.body, - assigneeTeammateId: "ling_agent", - requiredTools: scenario.requiredTools, - riskSummary: "Simulated crash after proposal persistence and before decision persistence.", - status: "draft", - }, "2026-08-16T11:00:00.000Z"); - - const first = await selectChannelWorkflowIntent({ - store, - channel, - sourceMessage: message, - secretResolver: createCompositeSecretResolver({ env: {} }), - }); - - assert.equal(first.action, "blocked"); - assert.equal(first.proposalId, proposal.id); - assert.equal(first.errorCode, "existing_proposal_participant_lineage_invalid"); - assert.equal(first.aiReply.status, "blocked"); - assert.equal(first.aiReply.errorMessage, `existing_proposal_participant_lineage_invalid:${proposal.id}`); - assert.equal(store.listChannelIntentDecisions({ sourceItemId: message.id, limit: 20 }).length, 0); - assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); - assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); - - const repeated = await selectChannelWorkflowIntent({ - store, - channel, - sourceMessage: message, - secretResolver: createCompositeSecretResolver({ env: {} }), - }); - assert.equal(repeated.action, "blocked"); - assert.equal(repeated.aiReply.id, first.aiReply.id); - assert.equal( - store.listConversationItemsForTurn({ turnId: store.getConversationItem(message.id).turnId }) - .filter((item) => item.kind === "assistant_message" && item.payload.deliveryStatus === "blocked").length, - 1, - ); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("partial existing proposal lineage is blocked consistently over JSON and SSE dispatch", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-partial-proposal-transport-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - const appendConversationUserTurn = store.appendConversationUserTurn.bind(store); - store.appendConversationUserTurn = (input, now) => { - const accepted = appendConversationUserTurn(input, now); - const existing = store.listChannelExecutionProposals({ - channelId: input.channelId, - sourceMessageId: accepted.item.id, - limit: 1, - })[0]; - if (!existing && input.userInstruction.includes("orphan-transport")) { - store.createChannelExecutionProposal({ - channelId: input.channelId, - sourceMessageId: accepted.item.id, - proposedByTeammateId: "ling_agent", - title: "Injected partial proposal lineage", - prompt: input.userInstruction, - assigneeTeammateId: "ling_agent", - requiredTools: ["write"], - riskSummary: "Injected between accepted-turn commit and selector dispatch.", - status: "draft", - }, now); - } - return accepted; - }; - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Partial proposal transport", - status: "active", - boundPath: cwd, - allowedTools: ["read", "write"], - })).channel; - const requestBody = { - body: "创建 orphan-transport-json.js,内容是 console.log('proof')。", - requestAiReply: true, - idempotencyKey: "partial-proposal-json", - }; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, requestBody); - assert.equal(created.aiReply.status, "blocked"); - assert.match(created.aiReply.errorMessage, /^existing_proposal_participant_lineage_invalid:proposal_/); - const replay = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, requestBody); - assert.equal(replay.message.id, created.message.id); - assert.equal(replay.aiReply.id, created.aiReply.id); - - const sseResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns/stream?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: "创建 orphan-transport-sse.js,内容是 console.log('proof')。", - requestAiReply: true, - idempotencyKey: "partial-proposal-sse", - }), - }); - assert.equal(sseResponse.status, 200); - const events = parseSseEvents(await sseResponse.text()); - assert.equal(events[0].event, "ready"); - assert.equal(events.at(-1).event, "blocked"); - assert.equal(events.some((event) => event.event === "error"), false); - assert.match(events.at(-1).data.aiReply.errorMessage, /^existing_proposal_participant_lineage_invalid:proposal_/); - - const store = stores.get(cwd); - assert.ok(store); - for (const sourceMessageId of [created.message.id, events[0].data.message.id]) { - const sourceItem = store.getConversationItem(sourceMessageId); - assert.ok(sourceItem?.turnId); - assert.equal(store.getLingTurn(sourceItem.turnId).status, "blocked"); - const dispatch = store.findWorkUnitByIdempotency({ - kind: "channel_turn_dispatch", - ownerType: "ling_turn", - ownerId: sourceItem.turnId, - idempotencyKey: `channel-turn-dispatch:${sourceItem.turnId}`, - }); - assert.equal(dispatch.status, "succeeded"); - assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) - .filter((item) => item.kind === "assistant_message" && item.payload.deliveryStatus === "blocked").length, 1); - assert.equal(store.listChannelIntentDecisions({ sourceItemId: sourceMessageId, limit: 20 }).length, 0); - } - assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); - assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); - assert.equal(runner.runs.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("explicit custom executable participant is preserved ahead of same-role fallback", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-preservation-")); - const command = `node -e "process.stdout.write('proof:7319')"`; - const systemPrompt = "PROMISEBOT_BASE_PROFILE_MARKER: return the configured participant behavior."; - const appendSystemPrompt = "PROMISEBOT_APPEND_PROFILE_MARKER: preserve the delegated participant profile."; - const approvalProfile = { - mode: "standard", - risk: "high", - requiredForTools: ["bash"], - requiredForActions: ["git_push"], - }; - const runner = new DelayedCommandEvidenceRunner(command); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ channels: { intentSelector: true, terminalReports: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const builtInEngineerId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(builtInEngineerId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - allowedTools: ["read", "bash"], - }); - const customEngineer = (await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { - roleKey: "engineer", - displayName: "PromiseBot", - description: "Runs the explicitly assigned verification command.", - providerPresetId: provider.id, - systemPrompt, - appendSystemPrompt: [appendSystemPrompt], - allowedTools: ["read", "bash"], - approvalProfile, - status: "active", - })).teammate; - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Explicit participant preservation", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: builtInEngineerId, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - for (const teammateId of [builtInEngineerId, customEngineer.id]) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `;@PromiseBot 运行 \`${command}\` 并验证输出严格等于 proof:7319。`, - mentions: [ - { id: customEngineer.id, label: customEngineer.displayName, kind: "ai-teammate" }, - { id: customEngineer.id, label: customEngineer.displayName, kind: "ai-teammate" }, - ], - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - const store = stores.get(cwd); - assert.ok(store); - const sourceItem = store.getConversationItem(created.message.id); - assert.deepEqual(sourceItem.payload.mentions.map((mention) => mention.id), [customEngineer.id]); - const workflowId = created.autoStartedWorkflow.workflow.id; - const decision = store.listChannelIntentDecisions({ sourceItemId: sourceItem.id, limit: 20 }) - .find((candidate) => candidate.workflowId === workflowId); - assert.ok(decision); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), [customEngineer.id]); - assert.deepEqual(decision.selectedTeammateIds, [customEngineer.id]); - const proposal = store.listChannelExecutionProposals({ - channelId: channel.id, - sourceMessageId: sourceItem.id, - limit: 20, - })[0]; - assert.ok(proposal); - assert.equal(proposal.assigneeTeammateId, customEngineer.id); - assert.deepEqual(proposal.collaborationSteps.map((step) => step.teammateId), [customEngineer.id]); - assert.deepEqual( - created.autoStartedWorkflow.workflow.metadata.requiredParticipantManifest.participants - .map((participant) => participant.participantId), - [customEngineer.id], - ); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const [step] = created.autoStartedWorkflow.workflowSteps; - assert.equal(step.toParticipantId, customEngineer.id); - assert.equal(step.participantAuthority.kind, "channel_selection"); - assert.equal(step.participantAuthority.origin, "channel_intent_proposal"); - assert.equal(step.participantAuthority.sourceId, proposal.id); - assert.equal(step.participantAuthority.participantId, customEngineer.id); - assert.deepEqual(step.metadata.providerBinding, { - schemaVersion: 1, - participantId: customEngineer.id, - bindingMode: "fixed", - source: "teammate_fixed", - providerPresetId: provider.id, - }); - assert.equal(step.metadata.delegatedRequirement.participantId, customEngineer.id); - await waitForCondition(() => runner.executionRuns.length === 1, "explicit custom participant execution run to start"); - const executionRun = runner.executionRuns[0]; - assert.equal(executionRun.systemPrompt, systemPrompt); - assert.equal(executionRun.appendSystemPrompt.includes(appendSystemPrompt), true); - assert.equal(executionRun.appendSystemPrompt.filter((prompt) => prompt.startsWith("Ling conversation response style v1:")).length, 1); - assert.deepEqual(executionRun.tools, ["bash"]); - const run = store.getRun(store.getChannelWorkflowStep(step.id).runId); - assert.equal(run.teammateId, customEngineer.id); - assert.equal(run.requestSnapshot.systemPrompt, systemPrompt); - assert.equal(run.requestSnapshot.appendSystemPrompt.includes(appendSystemPrompt), true); - assert.deepEqual(run.requestSnapshot.approvalProfile, approvalProfile); - assert.deepEqual(run.requestSnapshot.tools, ["bash"]); - assert.deepEqual(run.requestSnapshot.providerBinding, step.metadata.providerBinding); - assert.equal(JSON.stringify({ decision, proposal, workflow: created.autoStartedWorkflow.workflow, step, run }).includes(builtInEngineerId), false); - - const assigneeOverride = resolveWorkflowParticipantRunProfile({ - store, - channel: store.getChannel(channel.id), - step: store.getChannelWorkflowStep(step.id), - requestedAssigneeTeammateId: builtInEngineerId, - requiredTools: ["bash"], - }); - assert.equal(assigneeOverride.kind, "blocked"); - assert.equal(assigneeOverride.code, "workflow_participant_override_forbidden"); - assert.deepEqual(assigneeOverride.missingRequirements, ["assigned_participant"]); - - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(customEngineer.id)}?cwd=${encodeURIComponent(cwd)}`, { - allowedTools: ["read"], - }); - const toolShrink = resolveWorkflowParticipantRunProfile({ - store, - channel: store.getChannel(channel.id), - step: store.getChannelWorkflowStep(step.id), - requestedAssigneeTeammateId: customEngineer.id, - requiredTools: ["bash"], - }); - assert.equal(toolShrink.kind, "blocked"); - assert.equal(toolShrink.code, "workflow_participant_required_tools_unavailable"); - assert.deepEqual(toolShrink.missingRequirements, ["tool:bash"]); - assert.equal(runner.executionRuns.length, 1); - - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(customEngineer.id)}?cwd=${encodeURIComponent(cwd)}`, { - status: "paused", - }); - const recovered = await selectChannelWorkflowIntent({ - store, - channel: store.getChannel(channel.id), - sourceMessage: projectConversationItemToChannelMessage(sourceItem), - secretResolver: createCompositeSecretResolver({ env: {} }), - }); - assert.equal(recovered.action, "workflow_proposal"); - assert.equal(recovered.proposal.id, proposal.id); - assert.equal(recovered.proposal.assigneeTeammateId, customEngineer.id); - assert.equal(recovered.intentDecisionId, decision.id); - } finally { - runner.releaseExecution(); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow-definition authority does not require a direct Channel membership row", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-definition-authority-")); - t.after(() => rm(cwd, { recursive: true, force: true })); - const store = createSqliteRunStore(cwd); - try { - const now = "2026-08-16T02:00:00.000Z"; - const channel = store.createChannel({ - name: "Workflow authority", - boundPath: cwd, - allowedTools: ["read"], - }, now); - store.replaceTeammateChannelMemberships("teammate_builtin_engineer", [], now); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Graph-owned participant", - objective: "Run an assigned graph participant.", - status: "ready", - createdBy: "local-user", - }, now); - const step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - title: "Engineer step", - prompt: "Read the workspace.", - toParticipantId: "teammate_builtin_engineer", - toParticipantLabel: "Engineer", - participantAuthority: createWorkflowParticipantAuthorityV1({ - kind: "workflow_definition", - origin: "workflow_graph", - participantId: "teammate_builtin_engineer", - sourceId: "graph-authority-test", - }), - metadata: { requiredTools: ["read"] }, - }, now); - const resolution = resolveWorkflowParticipantRunProfile({ - store, - channel, - step, - requiredTools: ["read"], - }); - assert.equal(resolution.kind, "ready"); - assert.equal(resolution.teammateId, "teammate_builtin_engineer"); - } finally { - store.close(); - } -}); - -test("proposal provider preflight blocks missing workspace default and freezes configured participant bindings", async (t) => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-provider-preflight-")); - t.after(() => rm(cwd, { recursive: true, force: true })); - const store = createSqliteRunStore(cwd); - try { - const now = "2026-08-17T02:00:00.000Z"; - const channel = store.createChannel({ - name: "Provider preflight", - boundPath: cwd, - defaultProviderPresetId: "provider_channel_ling_agent", - allowedTools: ["read"], - }, now); - for (const teammateId of ["teammate_builtin_engineer", "teammate_builtin_qa"]) { - store.replaceTeammateChannelMemberships(teammateId, [{ channelId: channel.id }], now); - } - const thread = store.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: channel.id, - title: "Engineer and QA", - objective: "Engineer implements and QA verifies.", - }, now).thread; - const accepted = store.appendConversationUserTurn({ - channelId: channel.id, - threadId: thread.id, - title: "Engineer and QA", - userInstruction: "Engineer and QA", - payload: { body: "Engineer and QA", format: "plain_text", mentions: [], attachmentIds: [], linkPreviewIds: [], fileRefs: [] }, - idempotencyKey: "workflow-provider-preflight", - }, now); - const manifest = createRequiredParticipantManifestV1([ - { - participantId: "teammate_builtin_engineer", - role: "engineer", - requirement: "required", - expectedOutcome: "execution", - requiredOperationIds: [], - requestedBy: "structured_mention", - }, - { - participantId: "teammate_builtin_qa", - role: "qa", - requirement: "required", - expectedOutcome: "verification", - requiredOperationIds: [], - requestedBy: "structured_mention", - }, - ]); - const proposal = store.createChannelExecutionProposal({ - channelId: channel.id, - sourceMessageId: accepted.item.id, - proposedByTeammateId: "ling_agent", - title: "Engineer and QA", - prompt: "Engineer and QA", - assigneeTeammateId: "teammate_builtin_engineer", - collaborationSteps: [ - { teammateId: "teammate_builtin_engineer", action: "coordinated", summary: "Implement." }, - { teammateId: "teammate_builtin_qa", action: "reviewed", summary: "Verify." }, - ], - status: "draft", - }, now); - store.createChannelIntentDecision({ - channelId: channel.id, - sourceItemId: accepted.item.id, - contextProjection: projectionFor( - accepted.item, - store.getEffectiveTurnContextAssignment(accepted.turnId), - ), - action: "workflow_proposal", - selectedTeammateIds: ["teammate_builtin_engineer", "teammate_builtin_qa"], - participantManifest: manifest, - proposalId: proposal.id, - reason: "Engineer implements and QA verifies.", - status: "accepted", - createdBy: "deterministic", - }, now); - - let validationCalls = 0; - assert.throws(() => confirmChannelExecutionProposalWorkflow({ - store, - cwd, - proposalId: proposal.id, - validateRunRequest: () => { validationCalls += 1; }, - now, - }), /no workspace default is configured/); - assert.equal(validationCalls, 0); - const blocked = store.getChannelExecutionProposal(proposal.id); - assert.equal(blocked.status, "blocked"); - assert.equal(blocked.blocker.code, "workflow_participant_provider_unavailable"); - assert.equal(blocked.blocker.reasonCode, "workspace_default_provider_missing"); - assert.equal(blocked.blocker.owner, "workspace"); - assert.equal(blocked.blocker.repairTarget, "model_workbench"); - assert.equal(blocked.blocker.providerPresetId, undefined); - assert.equal(store.listChannelWorkflows({ channelId: channel.id, limit: 20 }).length, 0); - - store.updateWorkspaceSettings({ defaultProviderPresetId: "provider_workspace" }, now); - const validatedProviderIds = []; - const confirmed = confirmChannelExecutionProposalWorkflow({ - store, - cwd, - proposalId: proposal.id, - validateRunRequest: (request) => { validatedProviderIds.push(request.providerPresetId); }, - now: "2026-08-17T02:01:00.000Z", - }); - assert.deepEqual(validatedProviderIds, ["provider_workspace", "provider_workspace"]); - assert.equal(confirmed.proposal.status, "accepted"); - assert.equal(confirmed.proposal.blocker, undefined); - assert.equal(confirmed.proposal.errorMessage, undefined); - assert.deepEqual(confirmed.workflowSteps.map((step) => step.metadata.providerBinding), [ - { - schemaVersion: 1, - participantId: "teammate_builtin_engineer", - bindingMode: "workspace_default", - source: "workspace_default", - providerPresetId: "provider_workspace", - }, - { - schemaVersion: 1, - participantId: "teammate_builtin_qa", - bindingMode: "workspace_default", - source: "workspace_default", - providerPresetId: "provider_workspace", - }, - ]); - assert.deepEqual(confirmed.workflowSteps.map((step) => step.metadata.providerPresetId), [ - "provider_workspace", - "provider_workspace", - ]); - - const engineerStep = confirmed.workflowSteps[0]; - const engineer = store.getAiTeammate(engineerStep.toParticipantId); - assert.ok(engineer); - store.updateWorkspaceSettings({ defaultProviderPresetId: "provider_workspace_repaired" }, "2026-08-17T02:02:00.000Z"); - const resumedProviderIds = []; - const resumed = preflightWorkflowParticipantProvider({ - store, - channel, - teammate: engineer, - cwd, - validateRunRequest: (request) => { resumedProviderIds.push(request.providerPresetId); }, - frozenBinding: engineerStep.metadata.providerBinding, - }); - assert.equal(resumed.kind, "ready"); - assert.deepEqual(resumedProviderIds, ["provider_workspace"]); - assert.equal(resumed.binding.providerPresetId, "provider_workspace"); - - const unavailable = preflightWorkflowParticipantProvider({ - store, - channel, - teammate: engineer, - cwd, - validateRunRequest: () => { throw new Error("Provider disabled after proposal confirmation."); }, - frozenBinding: engineerStep.metadata.providerBinding, - }); - assert.equal(unavailable.kind, "blocked"); - assert.equal(unavailable.blocker.reasonCode, "provider_unavailable"); - assert.equal(unavailable.blocker.providerPresetId, "provider_workspace"); - assert.equal(unavailable.blocker.owner, "workspace"); - - const override = await prepareWorkflowStepStart( - store, - engineerStep.id, - { mode: "create_and_run_task", providerPresetId: "provider_workspace_repaired" }, - cwd, - () => undefined, - ); - assert.equal(override.kind, "blocked"); - const blockedStep = store.getChannelWorkflowStep(engineerStep.id); - assert.equal(blockedStep.metadata.delegatedOutcome.kind, "blocked"); - assert.equal(blockedStep.metadata.delegatedOutcome.code, "workflow_participant_provider_override_forbidden"); - assert.equal(blockedStep.taskId, undefined); - assert.equal(blockedStep.runId, undefined); - - const qaStep = confirmed.workflowSteps[1]; - const { - providerBinding: _providerBinding, - dependsOnStepIds: _dependsOnStepIds, - ...qaMetadataWithoutBinding - } = qaStep.metadata; - store.database.prepare("UPDATE channel_workflow_steps SET metadata_json = ? WHERE id = ?") - .run(JSON.stringify(qaMetadataWithoutBinding), qaStep.id); - const missingBinding = await prepareWorkflowStepStart( - store, - qaStep.id, - { mode: "create_and_run_task" }, - cwd, - () => undefined, - ); - assert.equal(missingBinding.kind, "blocked"); - assert.match(missingBinding.blocker, /no valid frozen provider binding/); - const missingBindingStep = store.getChannelWorkflowStep(qaStep.id); - assert.equal(missingBindingStep.taskId, undefined); - assert.equal(missingBindingStep.runId, undefined); - } finally { - store.close(); - } -}); - -test("explicit executable participant missing a required tool blocks without substitution", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-participant-tool-block-")); - const command = `node -e "process.stdout.write('proof:7319')"`; - const runner = new DelayedCommandEvidenceRunner(command); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const builtInEngineerId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(builtInEngineerId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - allowedTools: ["read", "bash"], - }); - const namedEngineer = (await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { - roleKey: "engineer", - displayName: "ReadOnlyBot", - description: "Explicitly assigned but intentionally lacks bash.", - providerPresetId: provider.id, - allowedTools: ["read"], - approvalProfile: { mode: "none" }, - status: "active", - })).teammate; - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Explicit participant tool blocker", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: builtInEngineerId, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - for (const teammateId of [builtInEngineerId, namedEngineer.id]) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `;@ReadOnlyBot 运行 \`${command}\` 并验证输出。`, - mentions: [{ id: namedEngineer.id, label: namedEngineer.displayName, kind: "ai-teammate" }], - requestAiReply: true, - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.executionRuns.length, 0); - const store = stores.get(cwd); - assert.ok(store); - const proposal = store.listChannelExecutionProposals({ - channelId: channel.id, - sourceMessageId: created.message.id, - limit: 20, - })[0]; - assert.ok(proposal); - assert.equal(proposal.status, "blocked"); - assert.equal(proposal.assigneeTeammateId, namedEngineer.id); - assert.match(proposal.errorMessage, /缺少所需工具[::]bash|missing required tools?:? bash/iu); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) - .find((candidate) => candidate.proposalId === proposal.id); - assert.ok(decision); - assert.deepEqual(decision.selectedTeammateIds, [namedEngineer.id]); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), [namedEngineer.id]); - assert.equal(decision.errorCode, "participant_required_tools_unavailable"); - assert.equal(JSON.stringify({ decision, proposal }).includes(builtInEngineerId), false); - } finally { - runner.releaseExecution(); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("distinct explicit same-role participants remain separate workflow requirements", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-same-role-participants-")); - const runner = new DelayedFileCommandRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ channels: { intentSelector: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const participants = []; - for (const displayName of ["BuilderOne", "BuilderTwo"]) { - participants.push((await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { - roleKey: "engineer", - displayName, - description: `${displayName} owns one explicitly assigned operation.`, - providerPresetId: provider.id, - allowedTools: ["read", "write", "bash"], - approvalProfile: { mode: "none" }, - status: "active", - })).teammate); - } - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Explicit same-role participants", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "write", "bash"], - })).channel; - for (const participant of participants) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(participant.id)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const requestBody = "@BuilderOne @BuilderTwo 创建 participant-proof.js,内容是 console.log('proof'),然后运行 node participant-proof.js 验证。"; - runner.selectorExecution = { schemaVersion: 2, operations: [ - fileOperationCandidate({ id: "create_file", path: "participant-proof.js", sourceQuote: requestBody, - owner: { participantId: participants[0].id, sourceQuote: "BuilderOne" }, - contentSource: { kind: "literal", text: "console.log('proof')", sourceQuote: "console.log('proof')" } }), - commandOperationCandidate({ command: "node participant-proof.js", sourceQuote: requestBody, dependsOn: ["create_file"], - owner: { participantId: participants[1].id, sourceQuote: "BuilderTwo" } }), - ] }; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: requestBody, - mentions: participants.map((participant) => ({ - id: participant.id, - label: participant.displayName, - kind: "ai-teammate", - })), - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - const participantIds = participants.map((participant) => participant.id); - const store = stores.get(cwd); - assert.ok(store); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) - .find((candidate) => candidate.workflowId === created.autoStartedWorkflow.workflow.id); - assert.ok(decision); - assert.deepEqual(decision.selectedTeammateIds, participantIds); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.participantId), participantIds); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requestedBy), ["structured_mention", "structured_mention"]); - assert.deepEqual(decision.participantManifest.participants.map((participant) => participant.requiredOperationIds), [["create_file"], ["run_command"]]); - const proposal = store.listChannelExecutionProposals({ - channelId: channel.id, - sourceMessageId: created.message.id, - limit: 20, - })[0]; - assert.deepEqual(proposal.collaborationSteps.map((step) => step.teammateId), participantIds); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 2); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.toParticipantId), participantIds); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps.map((step) => step.metadata.executionOperationIds), [["create_file"], ["run_command"]]); - await waitForCondition(() => runner.executionRuns.length === 1, "first explicit same-role participant run to start"); - } finally { - runner.releaseAll(); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("multi-participant executable request blocks when one explicit participant owns no required operation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-operation-assignment-block-")); - const command = `node -e "process.stdout.write('proof:7319')"`; - const runner = new DelayedCommandEvidenceRunner(command); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const participants = []; - for (const displayName of ["RunnerOne", "RunnerTwo"]) { - participants.push((await postJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}`, { - roleKey: "engineer", - displayName, - description: `${displayName} is explicitly requested for one command.`, - providerPresetId: provider.id, - allowedTools: ["read", "bash"], - approvalProfile: { mode: "none" }, - status: "active", - })).teammate); - } - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Explicit operation assignment blocker", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - for (const participant of participants) { - await putJson(`${daemon.url}/teammates/${encodeURIComponent(participant.id)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - runner.selectorExecution = { schemaVersion: 2, operations: [commandOperationCandidate({ command, - sourceQuote: `@RunnerOne @RunnerTwo 运行 \`${command}\` 并验证输出。`, - owner: { participantId: participants[0].id, sourceQuote: "RunnerOne" } })] }; - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `@RunnerOne @RunnerTwo 运行 \`${command}\` 并验证输出。`, - mentions: participants.map((participant) => ({ - id: participant.id, - label: participant.displayName, - kind: "ai-teammate", - })), - requestAiReply: true, - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.selectorRuns.length, 1, "the operation graph must reach the model intent selector"); - assert.equal(runner.executionRuns.length, 0); - const store = stores.get(cwd); - assert.ok(store); - const proposals = store.listChannelExecutionProposals({ - channelId: channel.id, - sourceMessageId: created.message.id, - limit: 20, - }); - assert.deepEqual(proposals, []); - const decision = store.listChannelIntentDecisions({ sourceItemId: created.message.id, limit: 20 }) - .find((candidate) => candidate.action === "clarify"); - assert.ok(decision); - assert.deepEqual(decision.clarification.missingFields, ["participant.operations"]); - assert.equal(store.listRuns({ channelId: channel.id, limit: 20 }).length, 0); - } finally { - runner.releaseExecution(); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-participant execute acknowledgement stays non-terminal until callback publication settles the source turn", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-participant-convergence-")); - const command = `node -e "process.stdout.write('proof:7319')"`; - const runner = new DelayedCommandEvidenceRunner(command); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, withRestrictedCommandReadiness({ channels: { intentSelector: true, terminalReports: true } })); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const engineerId = "teammate_builtin_engineer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(engineerId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Single participant convergence", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: engineerId, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - await putJson(`${daemon.url}/teammates/${encodeURIComponent(engineerId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `;@Engineer 运行 \`${command}\` 并验证输出严格等于 proof:7319。`, - requestAiReply: true, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const store = stores.get(cwd); - assert.ok(store); - const sourceItem = store.getConversationItem(created.message.id); - assert.ok(sourceItem?.turnId); - const workflowId = created.autoStartedWorkflow.workflow.id; - const [step] = created.autoStartedWorkflow.workflowSteps; - const acceptedDecision = store.listChannelIntentDecisions({ sourceItemId: sourceItem.id, limit: 20 }) - .find((decision) => decision.workflowId === workflowId); - assert.ok(acceptedDecision); - assert.deepEqual(acceptedDecision.participantManifest.participants.map((participant) => ({ - participantId: participant.participantId, - requirement: participant.requirement, - expectedOutcome: participant.expectedOutcome, - })), [{ - participantId: engineerId, - requirement: "required", - expectedOutcome: "execution", - }]); - await waitForCondition(() => runner.executionRuns.length === 1, "single participant execution run to start"); - const runningStep = store.getChannelWorkflowStep(step.id); - const runningRun = store.getRun(runningStep.runId); - assert.equal(runningRun.status, "running"); - assert.equal(runningRun.workflowStepId, step.id); - assert.equal(store.getLingTurn(sourceItem.turnId).status, "running"); - assert.equal(store.getConversationItem(created.aiReply.id).payload.deliveryStatus, "ready"); - assert.deepEqual(store.listTerminalReports({ sourceKind: "workflow", sourceId: workflowId, limit: 20 }), []); - - runner.releaseExecution(); - await waitForCondition( - () => store.getLingTurn(sourceItem.turnId)?.status === "succeeded", - "single participant callback publication to settle the source turn", - ); - - const finalRun = store.getRun(runningRun.id); - const finalStep = store.getChannelWorkflowStep(step.id); - const finalWorkflow = store.getChannelWorkflow(workflowId); - const reports = store.listTerminalReports({ sourceKind: "workflow", sourceId: workflowId, limit: 20 }); - assert.equal(finalRun.status, "succeeded"); - assert.equal(finalStep.status, "succeeded"); - assert.equal(finalStep.metadata.delegatedOutcome.kind, "completed"); - assert.equal(finalWorkflow.status, "succeeded"); - assert.equal(finalWorkflow.metadata.completionGate.status, "verified"); - assert.equal(reports.length, 1); - assert.ok(reports[0].resultItemId); - assert.equal(store.listConversationItemsForTurn({ turnId: sourceItem.turnId }) - .filter((item) => item.id === reports[0].resultItemId).length, 1); - assert.equal(store.listStateTransitionEvents({ entityType: "turn", entityId: sourceItem.turnId }) - .filter((event) => event.toStatus === "succeeded").length, 1); - assert.equal(store.listControlPlaneEvents({ - workspaceId: sourceItem.workspaceId, - domains: ["work_units"], - entityType: "terminal_report", - entityId: reports[0].id, - action: "terminal_report.published", - limit: 20, - }).length, 1); - } finally { - runner.releaseExecution(); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted create-and-run blocks an execute candidate missing its required command", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-create-run-command-clarify-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "execute", - reason: "User requested a file write and a follow-up run, but supplied no exact command.", - execution: { - schemaVersion: 2, - operations: [ - fileOperationCandidate({ id: "create_file", sourceQuote: "创建 app.js,内容是 console.log('ok')", path: "app.js", - contentSource: { kind: "literal", text: "console.log('ok')", sourceQuote: "console.log('ok')" } }), - commandOperationCandidate({ sourceQuote: "然后运行验证。", dependsOn: ["create_file"] }), - ], - }, - }), - ]); - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model create and run command clarify", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "write", "bash"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "创建 app.js,内容是 console.log('ok'),然后运行验证。", - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "command"); - assert.equal(runner.runs.length, 2, "one bounded repair before a failed planning diagnostic"); - await assert.rejects(access(resolve(cwd, "app.js"))); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted thread follow-up blocks an execute candidate instead of creating empty recent target", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-model-followup-empty-file-")); - let previousReply; - const runner = new ScriptedRunner([ - () => JSON.stringify({ - action: "execute", - reason: "User is asking Ling to write the previously discussed file locally.", - execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", - sourceQuote: "你现在可以帮我写在本地里面了吗?", path: "index.html", targetSourceItemId: previousReply.id })] }, - }), - ]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model thread follow-up empty target", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "先记一下以后可能要落地文件", - }, - "2026-06-12T09:00:00.000Z", - ); - previousReply = appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "如果确认,可以在工作区创建 `index.html`。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "你现在可以帮我写在本地里面了吗?", - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); - assert.equal(created.aiReply.authorId, "ling_agent"); - assert.match(created.aiReply.body, /未启动/u); - assert.doesNotMatch(created.aiReply.body, /contentSource/u); - await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "contentSource"); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - assert.equal(runner.runs.length, 2, "one bounded source-binding repair before a failed planning diagnostic"); - await assert.rejects(access(resolve(cwd, "index.html"))); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted local write request blocks an execute candidate when no target validates", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-followup-missing-target-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "execute", - reason: "User is asking Ling to write something locally.", - execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", - sourceQuote: "你现在可以帮我写在本地里面了吗?" })] }, - }), - ]); - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model local write clarify", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "你现在可以帮我写在本地里面了吗?", - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); - assert.equal(created.aiReply.authorId, "ling_agent"); - assert.match(created.aiReply.body, /未启动/u); - assert.doesNotMatch(created.aiReply.body, /\btarget\b/iu); - assert.doesNotMatch(created.aiReply.body, /不能直接修改工作区|cannot directly modify|没有被授权执行工作区写入/i); - assert.equal(runner.runs.length, 2, "one bounded source-binding repair before a failed planning diagnostic"); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - await assertInvalidModelPlan(daemon.url, cwd, channel.id, created.message.id, "target"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workspace follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-followup-file-ambiguous-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ambiguous follow-up execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我做一个登录页并补 README", - }, - "2026-06-12T09:00:00.000Z", - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "可以新建 `login.html`,也可以把说明补到 `README.md`。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "可以直接在工作区帮我做好吗?", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.status, "ready"); - assert.match(created.aiReply.body, /多个可能的文件目标/); - assert.match(created.aiReply.body, /login\.html/); - assert.match(created.aiReply.body, /README\.md/); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "clarify"); - assert.match(decisions.decisions[0].reason, /multiple file targets/i); - assert.equal(runner.runs.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("thread follow-up clarifies when the previous Ling Agent reply names multiple files", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-followup-file-ambiguous-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ambiguous thread follow-up execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "帮我做一个登录页面和说明文档", - }, - "2026-06-12T09:00:00.000Z", - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: previousUser.threadId, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "可以新建 `login.html`,也可以把说明补到 `README.md`。", - status: "ready", - }, - "2026-06-12T09:01:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "你直接帮我写到里面", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.status, "ready"); - assert.equal(created.aiReply.threadId, created.message.threadId); - assert.match(created.aiReply.body, /多个可能的文件目标/); - assert.match(created.aiReply.body, /login\.html/); - assert.match(created.aiReply.body, /README\.md/); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "clarify"); - assert.match(decisions.decisions[0].reason, /multiple file targets/i); - assert.equal(runner.runs.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("text attachment preview can feed a direct README write workflow", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-preview-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Attachment README execution", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "readme-copy.txt", - contentType: "text/plain", - contentBase64: Buffer.from("README body from attachment", "utf8").toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把附件内容写进 README.md", - attachmentIds: [attachment.id], - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.equal(runner.runs.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.match(proposals.proposals[0].prompt, /Attachment: readme-copy\.txt/); - assert.match(proposals.proposals[0].prompt, /README body from attachment/); - assert.match(runner.runs[0].task.prompt, /README body from attachment/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.workflowId === created.autoStartedWorkflow.workflow.id && /proposal .*: (?:started|queued)\./u.test(decision.reason)), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("text attachment materialization cache hit avoids rereading attachment file", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-cache-hit-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Attachment materialization cache hit", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "readme-cache.txt", - contentType: "text/plain", - contentBase64: Buffer.from("Cached attachment body", "utf8").toString("base64"), - })).attachment; - - const first = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把附件内容写进 README.md", - attachmentIds: [attachment.id], - }); - assert.equal(first.autoStartedWorkflow.workflowSteps.length, 1); - assert.equal(runner.runs.length, 1); - - const store = stores.get(cwd); - assert.ok(store); - const rows = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(rows.length, 1); - assert.equal(rows[0].extractorId, "text_attachment_v1"); - assert.equal(rows[0].status, "available"); - assert.equal(rows[0].contentText, "Cached attachment body"); - - await rm(resolve(store.paths.attachmentsDir, attachment.path), { force: true }); - - const second = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把附件内容写进 SECOND.md", - attachmentIds: [attachment.id], - }); - assert.equal(second.autoStartedWorkflow.workflowSteps.length, 1); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[1].task.prompt, /Cached attachment body/); - const afterCacheHit = store.listChannelInputMaterializations({ - channelId: channel.id, - sourceKind: "attachment", - sourceId: attachment.id, - }); - assert.equal(afterCacheHit.length, 1); - assert.equal(afterCacheHit[0].status, "available"); - assert.equal(afterCacheHit[0].contentText, "Cached attachment body"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("attachment-dependent README write blocks when attachment content is not materialized", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-readme-blocked-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Attachment README blocked", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - const oversized = "x".repeat(20 * 1024); - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "large-readme-source.txt", - contentType: "text/plain", - contentBase64: Buffer.from(oversized, "utf8").toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把附件内容写进 README.md", - attachmentIds: [attachment.id], - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.runs.length, 0); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "blocked"); - assert.match(proposals.proposals[0].errorMessage, /可读的附件内容|readable attachment content/i); - assert.doesNotMatch(proposals.proposals[0].errorMessage, /附件预览|attachment preview|too large|previewStatus/i); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "execute"); - assert.match(decisions.decisions[0].reason, /no available attachment preview\/extraction/i); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("unreadable screenshot task creates Ling materialization clarify reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-screenshot-materialization-clarify-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Screenshot materialization clarify", - status: "active", - boundPath: cwd, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "edit"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "screenshot.png", - contentType: "image/png", - contentBase64: Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "根据这个截图修 UI", - attachmentIds: [attachment.id], - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.runs.length, 0); - assert.match(created.aiReply.body, /没能读取 screenshot\.png|could not read image content/i); - assert.doesNotMatch(created.aiReply.body, /OCR|视觉提取|metadata_only|extractor|SSRF/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "clarify"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.match(decisions.decisions[0].reason, /Attachment content is not materialized/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("blocked link body request creates Ling materialization clarify reply", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-link-materialization-clarify-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Link materialization clarify", - status: "active", - boundPath: cwd, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls"], - })).channel; - const linkPreview = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { - url: "http://127.0.0.1/private", - })).linkPreview; - assert.equal(linkPreview.status, "blocked"); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Please summarize this link", - linkPreviewIds: [linkPreview.id], - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.runs.length, 0); - assert.match(created.aiReply.body, /local\/private links automatically|本机或私有地址/i); - assert.doesNotMatch(created.aiReply.body, /body extraction is blocked|SSRF|metadata_only|extractor/i); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "clarify"); - assert.match(decisions.decisions[0].reason, /Link body materialization is blocked/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("Ling Agent fallback creates file proposal when no Docs teammate can run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-docs-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent fallback docs", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `在 ${cwd} 下创建 README.md`, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); - assert.equal(proposals.proposals[0].proposedByTeammateId, "ling_agent"); - assert.deepEqual(proposals.proposals[0].collaborationSteps.map((step) => step.teammateId), ["ling_agent"]); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); - - assert.equal(created.autoStartedWorkflow.proposal.status, "accepted"); - assert.equal(created.autoStartedWorkflow.workflow.createdBy, "ling_agent"); - assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantId, "ling_agent"); - assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantLabel, "Ling Agent"); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["write"]); - assert.match(runner.runs[0].task.prompt, /Assigned teammate: Ling Agent/); - assert.match(runner.runs[0].task.prompt, /Confirmed task-scoped tools:\nwrite/); - const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); - assert.equal(runs.runs.length, 1); - assert.equal(runs.runs[0].teammateId, "ling_agent"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("Ling Agent fallback edits code proposal when no Engineer teammate can run", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ling-agent-fallback-engineer-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Ling Agent fallback engineer", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "edit"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `修改 ${cwd}/src/app.ts,把标题改成 Ling`, - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.equal(proposals.proposals[0].assigneeTeammateId, "ling_agent"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["edit"]); - assert.match(proposals.proposals[0].riskSummary, /Ling Agent 可以先处理这个有边界的任务/); - assert.match(proposals.proposals[0].riskSummary, /工程师/); - - assert.equal(created.autoStartedWorkflow.workflowSteps[0].toParticipantId, "ling_agent"); - assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["edit"]); - assert.equal(runner.runs.length, 1); - assert.deepEqual(runner.runs[0].tools, ["edit"]); - assert.match(runner.runs[0].task.prompt, /Assigned teammate: Ling Agent/); - const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); - assert.equal(runs.runs.length, 1); - assert.equal(runs.runs[0].teammateId, "ling_agent"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-step file creation blocks when channel lacks the required write tool", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-channel-readonly-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Read only docs", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `在 ${cwd} 下创建 README.md`, - }); - - assert.equal(created.aiReply.status, "blocked"); - assert.doesNotMatch(created.aiReply.body, /Starting now|现在开始处理/u); - assert.match(created.aiReply.body, /blocked|阻塞/u); - assert.equal(created.autoStartedWorkflow.orchestration.status, "blocked"); - assert.match(created.autoStartedWorkflow.orchestration.blocker, /channel|tools|write/i); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.deepEqual(proposals.proposals[0].requiredTools, ["write"]); - assert.ok(proposals.proposals[0].errorMessage || created.autoStartedWorkflow.orchestration.blocker); - assert.match(proposals.proposals[0].riskSummary, /write/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - - assert.deepEqual(created.autoStartedWorkflow.workflowSteps[0].metadata.requiredTools, ["write"]); - assert.equal(created.autoStartedWorkflow.workflowSteps[0].metadata.sourceProposalId, proposals.proposals[0].id); - assert.equal(runner.runs.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-step file edit inherits target path from recent workflow context", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-continuation-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Docs edit continuation", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "write", "edit"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: `在 ${cwd} 下创建 README.md`, - }); - const createProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(createProposals.proposals.length, 1); - assert.equal(createProposals.proposals[0].status, "accepted"); - - const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "在 README.md 中添加一句测试语句", - }); - - assertCoordinatorWorkflowStartReply(edited); - assert.equal(edited.autoStartedWorkflow.workflowSteps.length, 1); - const editProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); - assert.equal(editProposals.proposals.length, 1); - assert.equal(editProposals.proposals[0].status, "accepted"); - assert.equal(editProposals.proposals[0].linkedWorkflowId, edited.autoStartedWorkflow.workflow.id); - assert.deepEqual(editProposals.proposals[0].requiredTools, ["edit"]); - assert.match(editProposals.proposals[0].title, /README\.md|\.\.\./); - assert.match(editProposals.proposals[0].prompt, new RegExp(`Target: ${escapeRegExp(cwd)}/README\\.md`)); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(edited.message.id)}`); - assert.equal(decisions.decisions[0].action, "execute"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-step file edit normalizes Users path without leading slash", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-file-users-path-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Docs users path edit", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_docs", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "edit"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const edited = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Users/tianxiao/ling-stack-test 下面,在 README.md 中添加一句测试语句", - }); - - assertCoordinatorWorkflowStartReply(edited); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.deepEqual(proposals.proposals[0].requiredTools, ["edit"]); - assert.match(proposals.proposals[0].title, /\/Users\/tianxiao\/ling-stack-test\/README\.md/); - assert.match(proposals.proposals[0].prompt, /Target: \/Users\/tianxiao\/ling-stack-test\/README\.md/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(edited.message.id)}`); - assert.deepEqual(routeDecisions.decisions, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("file creation how-to question remains ordinary Ling chat intent", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-howto-")); - const daemon = await startDaemonForTest(); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "How-to docs", - status: "active", - boundPath: cwd, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "如何创建 README.md?", - }); - - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assertSingleReplyIntentDecision(decisions.decisions, created.message.id); - - const whyQuestion = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "为什么不能创建 README.md?", - }); - assert.equal(whyQuestion.aiReply.status, "pending"); - assert.equal(whyQuestion.replyDispatch.disposition, "reply_requested"); - assert.equal(whyQuestion.replyDispatch.targetTeammateId, "ling_agent"); - const whyProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(whyQuestion.message.id)}`); - assert.deepEqual(whyProposals.proposals, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("single-step test request becomes direct workflow with bounded bash tools", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-single-step-test-")); - const daemon = await startDaemonForTest(); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "QA execution", - status: "active", - boundPath: cwd, - defaultTeammateId: "teammate_builtin_qa", - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash"], - })).channel; - await setWorkspaceDefaultProvider(daemon.url, cwd, channel.defaultProviderPresetId); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "开始执行测试", - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.equal(proposals.proposals[0].assigneeTeammateId, "teammate_builtin_qa"); - assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); - assert.match(proposals.proposals[0].riskSummary, /bash/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions[0].action, "execute"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("high-risk push merge request auto-starts when required tools are not denied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Release", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "帮我 push 合并代码", - }); - - assertCoordinatorWorkflowStartReply(created); - assert.equal(created.autoStartedWorkflow.workflowSteps.length, 1); - assert.equal(runner.runs.length, 1); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "accepted"); - assert.equal(proposals.proposals[0].linkedWorkflowId, created.autoStartedWorkflow.workflow.id); - assert.match(proposals.proposals[0].riskSummary, /^本次任务需要 bash 权限处理 Git push\/merge workflow。/u); - - const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflows.workflows.length, 1); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => /High-risk side effect is runtime-gated: git_push_or_merge/.test(decision.reason)), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("high-risk thread follow-up creates a proposal without auto-starting", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-thread-high-risk-push-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Release thread", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const previousUser = appendChannelItemFixture(store, - { - channelId: channel.id, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "这个分支后面要发版", - }, - "2026-06-12T09:00:00.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - surface: { kind: "topic", anchorItemId: previousUser.id }, - body: "帮我 push 合并代码", - mentions: [{ id: "ling_agent", label: "Ling Agent", kind: "ai-teammate" }], - requestAiReply: true, - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "draft"); - assert.equal(proposals.proposals[0].linkedWorkflowId, undefined); - assert.deepEqual(proposals.proposals[0].requiredTools, ["bash"]); - - const workflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflows.workflows.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.action === "execute" && decision.status === "accepted"), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("high-risk push merge request blocks before run when required tool is denied", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-high-risk-push-deny-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/approval-policy?cwd=${encodeURIComponent(cwd)}`, { - toolDecisions: { bash: "deny" }, - }); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Release", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "帮我 push 合并代码", - }); - - assert.equal(created.aiReply, undefined); - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(runner.runs.length, 0); - - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 1); - assert.equal(proposals.proposals[0].status, "draft"); - assert.equal(proposals.proposals[0].linkedWorkflowId, undefined); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.some((decision) => decision.status === "failed" && /denies required tools: bash/.test(decision.errorMessage ?? decision.reason)), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted channel intent no_reply falls through to ordinary Ling replies", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-no-reply-fallthrough-")); - const runner = new ScriptedRunner([ - JSON.stringify({ action: "no_reply", reason: "Not an executable task." }), - "这个问题应该由 Ling Agent 回复。", - ]); - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep"], - })).channel; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent("teammate_builtin_engineer")}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个实现边界怎么处理?", - }); - - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "这个问题应该由 Ling Agent 回复。"); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[0].systemPrompt, /channel intent selector/i); - assert.match(runner.runs[0].task.prompt, /Classify the latest user channel message into exactly one Ling intent/); - assert.match(runner.runs[0].task.prompt, /Available teammate roles JSON/); - assert.match(runner.runs[1].systemPrompt, /Do not include hidden proposal blocks/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(routeDecisions.decisions.length, 1); - assert.equal(routeDecisions.decisions[0].mode, "default_teammate"); - assert.equal(routeDecisions.decisions[0].action, "reply"); - assert.equal(routeDecisions.decisions[0].status, "accepted"); - assert.equal(routeDecisions.decisions[0].replyMessageId, created.aiReply.id); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "no_reply"); - assert.equal(decisions.decisions[0].status, "suppressed"); - assert.equal(decisions.decisions[0].createdBy, "model_policy"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model-assisted channel intent failure cannot become an ordinary reply or confirmable proposal", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-intent-model-fallback-")); - const runner = new ScriptedRunner([ - "not json", - `普通频道回复。{"title":"Unbound proposal","prompt":"Start three agents","acceptanceCriteria":["done"],"requiredTools":["read"]}`, - ]); - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "teammate_builtin_engineer", - autoReplyMode: "default_teammate", - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - })).channel; - for (const teammateId of ["teammate_builtin_architect", "teammate_builtin_engineer", "teammate_builtin_qa"]) { - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - } - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Architect 先拆,Engineer 实现,QA 验证", - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.kind, "ai_reply"); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, "channel_intent_selection_failed"); - assert.equal(runner.runs.length, 2, "initial selection and exactly one format repair"); - assert.ok(runner.runs.every(run => /channel intent selector/i.test(run.systemPrompt))); - assert.doesNotMatch(created.aiReply.body, /普通频道回复|ling_task_proposal/); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(proposals.proposals.length, 0); - const allProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); - assert.equal(allProposals.proposals.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].status, "failed"); - assert.equal(decisions.decisions[0].errorCode, "channel_intent_selection_failed"); - assert.deepEqual(decisions.decisions[0].resultItemIds, [created.aiReply.id]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("model execution proposal cannot declare command evidence authority", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-model-command-authority-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "execute", - reason: "Run Node.", - execution: { - schemaVersion: 2, - operations: [{ - ...commandOperationCandidate({ sourceQuote: "运行 node app.js", command: "node app.js" }), - commandMatchPolicy: { kind: "executable", executable: "node" }, - }], - }, - }), - "请确认是否按明确的本地命令执行。", - ]); - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Model command authority", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "bash"], - })).channel; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "运行 node app.js", - }); - - assert.equal(created.autoStartedWorkflow, undefined); - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.errorMessage, "channel_operation_plan_invalid"); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].action, "reply"); - assert.equal(decisions.decisions[0].status, "failed"); - assert.equal(decisions.decisions[0].errorCode, "channel_operation_plan_invalid"); - assert.equal(decisions.decisions[0].execution, undefined); - assert.equal(runner.runs.length, 2); - const observations = await getJson(`${daemon.url}/control-plane/events?cwd=${encodeURIComponent(cwd)}&domains=runners&limit=1000`); - const rejected = observations.events.filter(event => event.entityType === "channel_intent_selection" - && event.entityId === created.message.id && event.data.outcome === "shape_rejected"); - assert.deepEqual(rejected.map(event => event.data.issue), [{ path: "execution.operations[]", code: "unexpected_fields" }]); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.deepEqual(proposals.proposals, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("issue #421 article continuation stays reply while an explicit README save executes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-article-")); - // file_create needs an absent target; the generic channel test helper seeds a - // README.md for its Git baseline, which a real exclusive create must reject. - await ensureEmptyGitRepo(cwd); - let continuedReply; - let selectorCalls = 0; - let replies = 0; - const daemon = await startRealPiChannelFixture(cwd, (request) => { - if (/channel intent selector/i.test(systemText(request))) { - selectorCalls += 1; - if (selectorCalls <= 2) return { text: JSON.stringify({ action: "reply", - reason: selectorCalls === 1 ? "The user asked for conversational article content." : "The user confirmed that the article should continue in chat." }) }; - return { text: JSON.stringify({ - action: "execute", - reason: "The user explicitly requested a local README write.", - execution: { schemaVersion: 2, operations: [fileOperationCandidate({ id: "create_file", path: "README.md", - sourceQuote: "把刚才的文章保存到 README.md。", - contentSource: { kind: "literal", text: continuedReply.body, sourceQuote: continuedReply.body, sourceItemId: continuedReply.id } })] }, - }) }; - } - if (selectorCalls <= 2) { - replies += 1; - return { text: replies === 1 ? "提纲:一、可恢复记忆;二、选择性投影;三、多 Agent 一致性。" - : "正文:可恢复记忆的关键不是无限拼接历史,而是保留可追溯事实并按当前任务投影。" }; - } - if (!request.messages.some((message) => message.role === "tool")) return { tool: "write", input: { path: "README.md", content: continuedReply.body } }; - return { text: "README.md saved." }; - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, daemon.providerOverrides)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channelResponse = await fetch(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - name: "Issue 421 article continuity", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - }), - }); - const channelPayload = await channelResponse.json(); - assert.equal(channelResponse.ok, true, JSON.stringify(channelPayload)); - const channel = channelPayload.channel; - await assert.rejects(readFile(resolve(cwd, "README.md")), /ENOENT/); - - const article = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "帮我写一篇关于可恢复多 Agent 记忆的长文,先给提纲。", - teammateId: "ling_agent", - requestAiReply: true, - }); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, article.aiReply.id, "ready"); - - const continuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "需要,开始吧", - teammateId: "ling_agent", - requestAiReply: true, - }); - const continuedMessages = await waitForChannelMessageStatus( - daemon.url, - cwd, - channel.id, - continuation.aiReply.id, - "ready", - ); - continuedReply = continuedMessages.messages.find((message) => message.id === continuation.aiReply.id); - assert.match(continuedReply.body, /可恢复记忆/); - - const articleDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(article.message.id)}`); - const continuationDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(continuation.message.id)}`); - assertSingleReplyIntentDecision(articleDecisions.decisions, article.message.id); - assertSingleReplyIntentDecision(continuationDecisions.decisions, continuation.message.id); - assert.equal(articleDecisions.decisions[0].createdBy, "model_policy"); - assert.equal(continuationDecisions.decisions[0].createdBy, "model_policy"); - assert.equal(continuationDecisions.decisions[0].contextSegmentId, articleDecisions.decisions[0].contextSegmentId); - assert.equal(continuationDecisions.decisions[0].clarification, undefined); - assert.equal(continuationDecisions.decisions[0].execution, undefined); - const continuationProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(continuation.message.id)}`); - assert.deepEqual(continuationProposals.proposals, []); - - const save = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把刚才的文章保存到 README.md。", - teammateId: "ling_agent", - requestAiReply: true, - }); - assertCoordinatorWorkflowStartReply(save); - assert.equal(save.autoStartedWorkflow.workflowSteps.length, 1); - const saveProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(save.message.id)}`); - assert.equal(saveProposals.proposals.length, 1); - assert.equal(saveProposals.proposals[0].status, "accepted"); - assert.match(saveProposals.proposals[0].prompt, /create_file: file_create \(README\.md\)/); - const saveDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(save.message.id)}`); - assert.equal(saveDecisions.decisions.length, 2); - assert(saveDecisions.decisions.every((decision) => decision.action === "execute")); - assert(saveDecisions.decisions.every((decision) => decision.execution.operations[0].kind === "file_create")); - assert(saveDecisions.decisions.every((decision) => decision.execution.operations[0].targetKind === "file")); - assert(saveDecisions.decisions.every((decision) => decision.proposalId === saveProposals.proposals[0].id)); - assert(saveDecisions.decisions.every((decision) => { - assertIntentDecisionInspectPayloadIsSafe(decision); - return true; - })); - const classified = saveDecisions.decisions.find((decision) => decision.createdBy === "model_policy"); - const started = saveDecisions.decisions.find((decision) => decision.workflowId === save.autoStartedWorkflow.workflow.id); - assert.ok(classified); - assert.ok(started); - assert.equal(classified.execution.operations[0].binding.contentSource.text, continuedReply.body); - assert.deepEqual(classified.execution.operations[0].binding.contentSource.sourceRefs.map((ref) => ref.itemId), [continuedReply.id]); - assert.deepEqual(started.resultItemIds, [save.aiReply.id]); - assert.equal(replies, 2); - assert.equal(daemon.selectorRequests.length, 3); - await daemon.assertWorkflowCompleted(save, { files: [{ path: "README.md", content: continuedReply.body }], - actionTypes: ["write"], participantIds: ["ling_agent"] }); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("issue #421 unrelated snake-game request escapes an old target clarification", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-issue-421-segment-reset-")); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "clarify", - reason: "A local write needs a concrete destination.", - clarification: { missingFields: ["target"], question: "要保存到哪个目标文件?" }, - }), - JSON.stringify({ action: "reply", reason: "The snake-game design is independently answerable in chat." }), - "贪吃蛇玩法:方向键移动,吃到食物增长,撞墙或撞到自己结束。", - ]); - runner.segmentationResponses = [JSON.stringify({ - action: "propose_new", - confidence: 0.96, - relatedSegmentIds: [], - reason: "The snake-game design is unrelated to the unresolved file destination.", - })]; - const daemon = await startDaemonForTest(runner, {}, { channels: { intentSelector: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { capabilities: ["chat", "tool_call"] })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Issue 421 clarification isolation", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "mentions_only", - allowedTools: ["read", "grep", "find", "ls", "write"], - })).channel; - - const oldRequest = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "把这段内容写到本地文件里。", - teammateId: "ling_agent", - requestAiReply: true, - }); - assert.equal(oldRequest.aiReply.status, "ready"); - assert.match(oldRequest.aiReply.body, /目标文件/); - const oldDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(oldRequest.message.id)}`); - assert.equal(oldDecisions.decisions.length, 1); - assert.equal(oldDecisions.decisions[0].action, "clarify"); - assert.deepEqual(oldDecisions.decisions[0].clarification.missingFields, ["target"]); - - const snake = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "再做一个贪吃蛇小游戏的方案,先讲玩法。", - teammateId: "ling_agent", - requestAiReply: true, - }); - const snakeMessages = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, snake.aiReply.id, "ready"); - const snakeReply = snakeMessages.messages.find((message) => message.id === snake.aiReply.id); - assert.match(snakeReply.body, /贪吃蛇玩法/); - assert.doesNotMatch(snakeReply.body, /目标文件|target file/i); - const snakeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(snake.message.id)}`); - assertSingleReplyIntentDecision(snakeDecisions.decisions, snake.message.id); - assert.equal(snakeDecisions.decisions[0].createdBy, "model_policy"); - assert.notEqual(snakeDecisions.decisions[0].contextSegmentId, oldDecisions.decisions[0].contextSegmentId); - assert.equal(runner.segmentationRuns.length, 1); - const snakeProposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(snake.message.id)}`); - assert.deepEqual(snakeProposals.proposals, []); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("default_teammate route suppresses duplicate Ling Agent replies for the same source message", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-idempotent-")); - const now = "2026-06-09T00:00:00.000Z"; - const store = createSqliteRunStore(cwd); - try { - const provider = store.createProviderPreset( - { - kind: "glm", - displayName: "Static Chat", - providerId: "static-chat", - model: "static-model", - api: "google-generative-ai", - capabilities: ["chat"], - input: ["text"], - authHeader: false, - enabled: true, - }, - now, - ); - store.recordProviderCheckResult(provider.id, { - ok: true, - code: "static_check", - message: "Provider configuration is valid.", - checkedAt: now, - }); - const channel = store.createChannel( - { - name: "Engineering", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "engineer", - autoReplyMode: "default_teammate", - }, - now, - ); - const sourceMessage = appendChannelItemFixture(store, - { - channelId: channel.id, - authorType: "local-user", - body: "这个问题应该怎么回复?", - }, - now, - ); - appendChannelItemFixture(store, - { - channelId: channel.id, - replyToItemId: sourceMessage.id, - kind: "ai_reply", - authorType: "ai-teammate", - authorId: "ling_agent", - body: "Existing reply.", - }, - now, - ); - - const route = await routeChannelMessageReply({ - store, - channel, - sourceMessage, - config: testDaemonConfig(cwd), - secretResolver: createCompositeSecretResolver({ env: {} }), - runnerRegistry: unusedRunnerRegistry(), - }); - - assert.equal(route.action, "no_reply"); - assert.equal(route.reason, "An AI reply already exists for this source message."); - const decisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: sourceMessage.id }); - assert.equal(decisions.length, 1); - assert.equal(decisions[0].status, "suppressed"); - assert.equal(decisions[0].reason, "An AI reply already exists for this source message."); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("default_teammate route treats channel branches and topic replies as separate surfaces", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-surface-route-")); - const now = "2026-06-25T00:00:00.000Z"; - const store = createSqliteRunStore(cwd); - try { - const provider = store.createProviderPreset( - { - kind: "glm", - displayName: "Static Chat", - providerId: "static-chat", - model: "static-model", - api: "google-generative-ai", - capabilities: ["chat"], - input: ["text"], - authHeader: false, - enabled: true, - }, - now, - ); - store.recordProviderCheckResult(provider.id, { - ok: true, - code: "static_check", - message: "Provider configuration is valid.", - checkedAt: now, - }); - const channel = store.createChannel( - { - name: "Engineering", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "engineer", - autoReplyMode: "default_teammate", - }, - now, - ); - const rootThread = store.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: channel.id, - title: "Root", - objective: "Root conversation", - }, now).thread; - const rootMessage = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body: "Create a branch", - }, now); - const rootItem = store.getConversationItem(rootMessage.id); - assert.ok(rootItem?.turnId); - const rootAssignment = store.getEffectiveTurnContextAssignment(rootItem.turnId); - assert.ok(rootAssignment); - const branchThread = store.resolveOrCreateConversationThread({ - id: "thread_branch_root", - surfaceKind: "branch", - channelId: channel.id, - parentThreadId: rootThread.id, - parentTurnId: rootItem.turnId, - title: "Branch", - objective: "Branch conversation", - forkContextSnapshot: { - id: "snapshot_branch_root", - segmentId: rootAssignment.segmentId, - projectionVersion: "ctxv2_branch_root", - throughItemSequence: rootItem.sequence, - summaryIds: [], - sourceRefs: [{ type: "conversation_item", id: rootItem.id }], - policyVersion: "continuity-v2-snapshot-v1", - }, - }, now).thread; - const branch = store.createChannelBranch( - { - channelId: channel.id, - name: "test", - rootThreadId: branchThread.id, - }, - now, - ); - const branchMessage = appendChannelItemFixture(store, - { - channelId: channel.id, - threadId: branch.rootThreadId, - authorType: "local-user", - body: "分支里也需要继续回复。", - }, - now, - ); - const topicSource = appendChannelItemFixture(store, - { - channelId: channel.id, - authorType: "local-user", - body: "主频道源消息。", - }, - now, - ); - const topicReply = appendChannelItemFixture(store, - { - channelId: channel.id, - topicAnchorItemId: topicSource.id, - authorType: "local-user", - body: "这是 topic reply,不应该自动拉起 agent。", - }, - now, - ); - - const sharedRouteInput = { - store, - channel, - config: testDaemonConfig(cwd), - secretResolver: createCompositeSecretResolver({ env: {} }), - runnerRegistry: unusedRunnerRegistry(), - }; - const branchRoute = await routeChannelMessageReply({ - ...sharedRouteInput, - sourceMessage: branchMessage, - }); - const topicRoute = await routeChannelMessageReply({ - ...sharedRouteInput, - sourceMessage: topicReply, - }); - - assert.equal(branchRoute.action, "reply"); - assert.equal(branchRoute.teammateId, "ling_agent"); - assert.equal(branchRoute.reason, "Ling Agent answered a channel branch message."); - assert.equal(topicRoute.action, "no_reply"); - assert.equal(topicRoute.reason, "Topic replies are outside the first auto-reply scope."); - - const branchDecisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: branchMessage.id }); - assert.equal(branchDecisions.length, 1); - assert.equal(branchDecisions[0].action, "reply"); - assert.equal(branchDecisions[0].status, "accepted"); - const topicDecisions = store.listChannelMessageRouteDecisions({ channelId: channel.id, sourceMessageId: topicReply.id }); - assert.equal(topicDecisions.length, 1); - assert.equal(topicDecisions[0].action, "no_reply"); - assert.equal(topicDecisions[0].status, "suppressed"); - } finally { - store.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("Ling Agent replies to greetings, repeated greetings, punctuation, and acknowledgements", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-greeting-")); - const runner = new ScriptedRunner([ - "你好,我在。可以继续说你的问题。", - "我在,继续说。", - "晚上好,我在。", - "看到了,继续说。", - "收到,继续。", - ]); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - defaultTeammateId: "engineer", - autoReplyMode: "mentions_only", - allowedTools: ["read"], - })).channel; - - const preConfigGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "你好?", - }); - assert.equal(preConfigGreeting.aiReply.status, "pending"); - const preConfigListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, preConfigGreeting.aiReply.id, "ready"); - const preConfigReply = preConfigListed.messages.find((message) => message.id === preConfigGreeting.aiReply.id); - assert.equal(preConfigReply.body, "你好,我在。可以继续说你的问题。"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - autoReplyMode: "default_teammate", - }); - - const repeatedGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "你好?", - }); - assert.equal(repeatedGreeting.aiReply.status, "pending"); - const repeatedListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, repeatedGreeting.aiReply.id, "ready"); - const repeatedReply = repeatedListed.messages.find((message) => message.id === repeatedGreeting.aiReply.id); - assert.equal(repeatedReply.body, "我在,继续说。"); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(repeatedGreeting.message.id)}`); - assert.equal(decisions.decisions[0].action, "reply"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].replyMessageId, repeatedGreeting.aiReply.id); - - const eveningGreeting = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "晚上好", - }); - assert.equal(eveningGreeting.aiReply.status, "pending"); - const eveningListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, eveningGreeting.aiReply.id, "ready"); - const eveningReply = eveningListed.messages.find((message) => message.id === eveningGreeting.aiReply.id); - assert.equal(eveningReply.body, "晚上好,我在。"); - - const punctuation = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "。。", - }); - assert.equal(punctuation.aiReply.status, "pending"); - const punctuationListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, punctuation.aiReply.id, "ready"); - const punctuationReply = punctuationListed.messages.find((message) => message.id === punctuation.aiReply.id); - assert.equal(punctuationReply.body, "看到了,继续说。"); - - const acknowledgement = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "好的", - }); - assert.equal(acknowledgement.aiReply.status, "pending"); - const acknowledgementListed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, acknowledgement.aiReply.id, "ready"); - const acknowledgementReply = acknowledgementListed.messages.find((message) => message.id === acknowledgement.aiReply.id); - assert.equal(acknowledgementReply.body, "收到,继续。"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("default_teammate creates a blocked Ling Agent reply when the channel has no runnable provider", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-default-teammate-suppressed-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - simulateLegacyChannelWithoutModel(cwd, channel.id); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个问题谁来回复?", - }); - - assert.equal(created.aiReply.status, "blocked"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "blocked"); - const blockedReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(blockedReply.body, "AI teammate is not configured for this channel."); - assert.equal(blockedReply.errorMessage, "No provider preset is selected for this channel discussion."); - assert.equal(runner.runs.length, 0); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].mode, "default_teammate"); - assert.equal(decisions.decisions[0].action, "reply"); - assert.equal(decisions.decisions[0].status, "failed"); - assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("explicit mentions keep reply text but cannot create an unbound proposal", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-explicit-route-")); - const runner = new ScriptedRunner([ - `Visible explicit reply{"title":"Explicit proposal","prompt":"Create explicit task","acceptanceCriteria":["ok"],"requiredTools":["read"],"riskSummary":"explicit"}`, - ]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer 这个明确任务可以处理吗?", - mentions: [{ id: "designer", label: "Designer", kind: "ai-teammate" }], - requestAiReply: true, - }); - - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Visible explicit reply"); - assert.match(runner.runs[0].systemPrompt, /Do not include hidden proposal blocks/); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions[0].createdBy, "explicit_mention"); - assert.equal(decisions.decisions[0].status, "accepted"); - const proposals = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`); - assert.equal(proposals.proposals.length, 0); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("auto_route is rejected by channel create and update while the feature flag is disabled", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-disabled-")); - let channelStore; - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - if (storeCwd === cwd) { - channelStore = store; - } - return store; - }, - }, { channels: { autoRoute: false, intentSelector: false } }); - try { - const createUrl = `${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`; - const createBody = { - name: "Engineering", - boundPath: cwd, - autoReplyMode: "auto_route", - }; - const preparedCreateBody = await prepareChannelCreateBody(createUrl, createBody); - await ensureGitRepoForChannelPost(createUrl, preparedCreateBody); - const createResponse = await fetch(createUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(preparedCreateBody), - }); - assert.equal(createResponse.status, 400); - assert.equal((await createResponse.json()).error, "auto_route_disabled"); - - const channel = await createChannel(daemon.url, cwd); - const updateResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ autoReplyMode: "auto_route" }), - }); - assert.equal(updateResponse.status, 400); - assert.equal((await updateResponse.json()).error, "auto_route_disabled"); - - assert.ok(channelStore); - channelStore.updateChannel(channel.id, { autoReplyMode: "auto_route" }, new Date().toISOString()); - const renameResponse = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Renamed", autoReplyMode: "auto_route" }), - }); - assert.equal(renameResponse.status, 200); - const renamed = (await renameResponse.json()).channel; - assert.equal(renamed.name, "Renamed"); - assert.equal(renamed.autoReplyMode, "auto_route"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("auto_route uses the model selector and records accepted route evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-model-")); - const stores = new Map(); - const runner = new ScriptedRunner([ - JSON.stringify({ - action: "reply", - teammateId: "teammate_builtin_designer", - confidence: 0.9, - reason: "Designer owns UI wording.", - alternativesConsidered: [ - { - teammateId: "teammate_builtin_designer", - label: "Designer", - score: 0.9, - reason: "Best fit.", - }, - ], - }), - `{"title":"Should not persist","prompt":"Create auto task","acceptanceCriteria":["never"],"requiredTools":["read"],"riskSummary":"blocked by proposalMode"}Designer auto route reply`, - ]); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }, { channels: { autoRoute: true } }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Design", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "auto_route", - allowedTools: ["read"], - })).channel; - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - const store = stores.get(cwd); - assert.ok(store); - const oldGuidanceMessage = appendChannelItemFixture(store, - { - channelId: channel.id, - authorType: "local-user", - body: "排队引导:旧频道引导不应继续影响后续消息。", - }, - "2026-06-12T08:00:00.000Z", - ); - const oldRun = store.createRun({ - id: "run_old_channel_guidance", - cwd, - taskId: "task_old_channel_guidance", - channelId: channel.id, - teammateId: "ling_agent", - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_old_channel_guidance", title: "Old active work", prompt: "Old active work." }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd, task: { id: "task_old_channel_guidance", title: "Old active work", prompt: "Old active work.", channelId: channel.id } }, - createdAt: "2026-06-12T08:00:01.000Z", - }); - store.createRunIntervention( - { - sourceRunId: oldRun.id, - mode: "queue_followup", - instruction: oldGuidanceMessage.body, - requestedBy: "local-user", - idempotencyKey: `channel_message:${oldGuidanceMessage.id}:queue_followup`, - }, - "2026-06-12T08:00:02.000Z", - ); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个设置文案应该怎么改?", - }); - - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Designer auto route reply"); - assert.doesNotMatch(finalReply.body, /ling_task_proposal/); - assert.equal(runner.runs.length, 2); - assert.match(runner.runs[0].systemPrompt, /channel message router/i); - assert.match(runner.runs[0].task.prompt, /Decide whether one AI teammate should reply/); - assert.match(runner.runs[0].task.prompt, /Root local-user messages should produce a visible Ling outcome/); - assert.match(runner.runs[0].task.prompt, /Channel collaboration protocol:/); - assert.match(runner.runs[0].task.prompt, /Auto reply mode: auto_route \(source=channel_setting\)/); - assert.match(runner.runs[0].task.prompt, /Candidates JSON/); - assert.doesNotMatch(runner.runs[0].task.prompt, /旧频道引导不应继续影响后续消息/); - assert.match(runner.runs[1].systemPrompt, /Do not include hidden proposal blocks/); - - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions.length, 1); - assert.equal(decisions.decisions[0].mode, "auto_route"); - assert.equal(decisions.decisions[0].createdBy, "model"); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].selectedTeammateId, "ling_agent"); - assert.equal(decisions.decisions[0].confidence, 0.9); - assert.equal(decisions.decisions[0].replyMessageId, created.aiReply.id); - assert.equal(decisions.decisions[0].alternativesConsidered[0].teammateId, teammateId); - assert.equal(decisions.decisions[0].contextPack.kind, "auto_route_selector"); - assert.deepEqual(decisions.decisions[0].contextPack.sectionIds, [ - "auto_route_instructions", - "channel_collaboration_protocol", - "auto_route_source_message", - "auto_route_candidates", - ]); - assert.deepEqual(decisions.decisions[0].contextPack.sourceRefCounts, { - channel: 1, - message: 1, - thread: 1, - turn: 1, - conversation_item: 1, - }); - const createdItem = store.getConversationItem(created.message.id); - assert.ok(createdItem?.turnId); - assert.equal(decisions.decisions[0].contextPack.contextProjection.threadId, createdItem.threadId); - assert.equal(decisions.decisions[0].contextPack.contextProjection.turnId, createdItem.turnId); - assert.equal(typeof decisions.decisions[0].contextPack.contextProjection.projectionVersion, "string"); - assert.equal(decisions.decisions[0].contextPack.providerPresetId, provider.id); - assert.deepEqual((await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/proposals?cwd=${encodeURIComponent(cwd)}`)).proposals, []); - assert.deepEqual((await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks, []); - const runs = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}`); - const replyRuns = runs.runs.filter((run) => run.id !== oldRun.id); - assert.equal(replyRuns.length, 1); - assert.equal(replyRuns[0].id, finalReply.linkedRunId); - assert.equal(replyRuns[0].status, "succeeded"); - assert.equal(replyRuns[0].taskId, created.message.id); - const persistedReplyRun = store.getRun(replyRuns[0].id); - assert.equal( - persistedReplyRun.requestSnapshot.contextProjection.projectionVersion, - decisions.decisions[0].contextPack.contextProjection.projectionVersion, - ); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("auto_route falls back to Ling Agent replies on selector failure", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-auto-route-fallback-")); - const runner = new ScriptedRunner(["not json", "Ling fallback reply 1"]); - const daemon = await startDaemonForTest(runner, {}, { channels: { autoRoute: true } }); - try { - const channel = await createAutoRouteTestChannel(daemon.url, cwd); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个交给谁回复?", - }); - - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, "ling_agent"); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - assert.equal(runner.runs.length, 2); - let decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].createdBy, "model"); - assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); - - runner.responses.push(JSON.stringify({ - action: "reply", - teammateId: "teammate_builtin_designer", - confidence: 0.2, - reason: "Unsure.", - alternativesConsidered: [], - }), "Ling fallback reply 2"); - const lowConfidence = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个 UI 问题可以自动判断吗?", - }); - assert.equal(lowConfidence.aiReply.status, "pending"); - assert.equal(lowConfidence.aiReply.authorId, "ling_agent"); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, lowConfidence.aiReply.id, "ready"); - decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(lowConfidence.message.id)}`); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.match(decisions.decisions[0].reason, /Route model confidence was below threshold/); - - runner.responses.push(`Here is the decision: ${JSON.stringify({ - action: "reply", - teammateId: "teammate_builtin_designer", - confidence: 0.9, - reason: "Wrapped JSON should fail strict parsing.", - alternativesConsidered: [], - })}`, "Ling fallback reply 3"); - const wrappedJson = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个带解释的模型输出应该失败吗?", - }); - assert.equal(wrappedJson.aiReply.status, "pending"); - assert.equal(wrappedJson.aiReply.authorId, "ling_agent"); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, wrappedJson.aiReply.id, "ready"); - decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(wrappedJson.message.id)}`); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); - - runner.responses.push(JSON.stringify({ - action: "reply", - teammateId: "teammate_missing", - confidence: 0.9, - reason: "Unknown teammate should fail closed.", - alternativesConsidered: [], - }), "Ling fallback reply 4"); - const unknownTeammate = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "这个未知 teammate 应该失败吗?", - }); - assert.equal(unknownTeammate.aiReply.status, "pending"); - assert.equal(unknownTeammate.aiReply.authorId, "ling_agent"); - await waitForChannelMessageStatus(daemon.url, cwd, channel.id, unknownTeammate.aiReply.id, "ready"); - decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(unknownTeammate.message.id)}`); - assert.equal(decisions.decisions[0].status, "accepted"); - assert.equal(decisions.decisions[0].reason, "Ling Agent fallback replied after automatic teammate selection failed."); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel AI replies return pending message before runner finishes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-ai-reply-pending-")); - const runner = new DelayedRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createChannel(daemon.url, cwd); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const teammateId = "teammate_builtin_designer"; - await patchJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${daemon.url}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "@Designer async please", - mentions: [{ id: teammateId, label: "Designer", kind: "ai-teammate" }], - requestAiReply: true, - providerPresetId: provider.id, - teammateId, - }); - - assert.equal(created.aiReply.status, "pending"); - await waitForCondition(() => runner.runs.length === 1, "runner to start channel AI reply"); - assert.equal(runner.runs.length, 1); - assert.equal(runner.completed, false); - const pending = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/items?cwd=${encodeURIComponent(cwd)}`); - assert.equal(pending.messages.find((message) => message.id === created.aiReply.id).status, "pending"); - - runner.resolve("Delayed reply"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "Delayed reply"); - } finally { - runner.resolve("cleanup"); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel attachment upload validates file types and serves content safely", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachments-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createChannel(daemon.url, cwd); - const uploaded = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "brief.txt", - contentType: "text/plain", - contentBase64: Buffer.from("hello channel", "utf8").toString("base64"), - }); - assert.equal(uploaded.attachment.kind, "text"); - assert.equal(uploaded.attachment.fileName, "brief.txt"); - - const content = await fetch(`${daemon.url}/attachments/${encodeURIComponent(uploaded.attachment.id)}/content?cwd=${encodeURIComponent(cwd)}`); - assert.equal(content.status, 200); - assert.equal(await content.text(), "hello channel"); - - const typed = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "payload.txt", - contentType: "text/html", - contentBase64: Buffer.from("", "utf8").toString("base64"), - }); - const typedContent = await fetch(`${daemon.url}/attachments/${encodeURIComponent(typed.attachment.id)}/content?cwd=${encodeURIComponent(cwd)}`); - assert.match(typedContent.headers.get("content-type") ?? "", /^text\/plain/); - - const rejected = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - fileName: "archive.zip", - contentBase64: Buffer.from("zip").toString("base64"), - }), - }); - assert.equal(rejected.status, 400); - assert.match((await rejected.json()).message, /Unsupported attachment type/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("attachment-only channel turn reaches Ling Agent with metadata and preview", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-attachment-only-turn-")); - const runner = new ScriptedRunner(["看到了 brief.txt。你要我总结、写入文件,还是作为当前任务上下文?"]); - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Attachment turn", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "default_teammate", - allowedTools: ["read"], - })).channel; - const attachment = (await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "brief.txt", - contentType: "text/plain", - contentBase64: Buffer.from("hello channel attachment", "utf8").toString("base64"), - })).attachment; - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "", - attachmentIds: [attachment.id], - }); - - assert.equal(created.aiReply.status, "pending"); - assert.equal(created.aiReply.authorId, "ling_agent"); - const listed = await waitForChannelMessageStatus(daemon.url, cwd, channel.id, created.aiReply.id, "ready"); - const finalReply = listed.messages.find((message) => message.id === created.aiReply.id); - assert.equal(finalReply.body, "看到了 brief.txt。你要我总结、写入文件,还是作为当前任务上下文?"); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Turn envelope/); - assert.match(runner.runs[0].task.prompt, /brief\.txt/); - assert.match(runner.runs[0].task.prompt, /hello channel attachment/); - const routeDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/message-route-decisions?cwd=${encodeURIComponent(cwd)}&sourceMessageId=${encodeURIComponent(created.message.id)}`); - assert.equal(routeDecisions.decisions.length, 1); - assert.equal(routeDecisions.decisions[0].action, "reply"); - const intentDecisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(created.message.id)}`); - assertSingleReplyIntentDecision(intentDecisions.decisions, created.message.id); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel asset references must stay within the same channel", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-reference-")); - const daemon = await startDaemonForTest(); - try { - const first = await createChannel(daemon.url, cwd); - const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "source message", - }); - const attachment = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - fileName: "brief.txt", - contentBase64: Buffer.from("brief", "utf8").toString("base64"), - messageId: message.message.id, - }); - const preview = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { - url: "http://localhost:12345/private", - }); - - const crossUpload = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/attachments?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - fileName: "other.txt", - contentBase64: Buffer.from("other", "utf8").toString("base64"), - messageId: message.message.id, - }), - }); - assert.equal(crossUpload.status, 400); - assert.equal((await crossUpload.json()).error, "message_channel_mismatch"); - - const crossMessage = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - body: "cross channel reference", - attachmentIds: [attachment.attachment.id], - linkPreviewIds: [preview.linkPreview.id], - }), - }); - assert.equal(crossMessage.status, 400); - assert.equal((await crossMessage.json()).error, "attachment_channel_mismatch"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel collaboration protocol resolver API patches existing channel fields and reverts to defaults", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-settings-")); - const daemon = await startDaemonForTest(undefined, {}, { channels: { autoRoute: false } }); - try { - await patchJson(`${daemon.url}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { - responseLanguage: "zh-CN", - }); - const channel = await createChannel(daemon.url, cwd); - - const initial = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`); - assert.equal(initial.protocol.schemaVersion, 1); - assert.equal(initial.protocol.channelId, channel.id); - assert.equal(initial.protocol.responseLanguage, "zh-CN"); - assert.equal(initial.sources.responseLanguage.source, "workspace_setting"); - assert.equal(initial.sources.responseLanguage.channelValue, "workspace"); - assert.equal(initial.sources.responseLanguage.workspaceValue, "zh-CN"); - assert.equal(initial.protocol.autoReplyMode, "mentions_only"); - assert.equal(initial.sources.autoReplyMode.source, "default"); - assert.equal(initial.protocol.defaultTeammateId, undefined); - assert.equal(initial.sources.defaultTeammateId.source, "default"); - assert.equal(initial.storage.mode, "channel_fields_and_protocol_overrides"); - assert.deepEqual(initial.storage.editableFields, ["responseLanguage", "autoReplyMode", "defaultTeammateId", "defaultWorkflowPreference", "visibleMultiReplyMaxParticipants", "privacyRules", "reviewRules"]); - assert.equal(initial.storage.unsupportedFields.length, 0); - - const patched = await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { - responseLanguage: "en-US", - autoReplyMode: "default_teammate", - defaultTeammateId: "teammate_builtin_engineer", - }); - assert.equal(patched.protocol.responseLanguage, "en-US"); - assert.equal(patched.sources.responseLanguage.source, "channel_setting"); - assert.equal(patched.protocol.autoReplyMode, "default_teammate"); - assert.equal(patched.sources.autoReplyMode.source, "channel_setting"); - assert.equal(patched.protocol.defaultTeammateId, "teammate_builtin_engineer"); - assert.equal(patched.sources.defaultTeammateId.source, "channel_setting"); - const channelAfterPatch = (await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`)).channel; - assert.equal(channelAfterPatch.responseLanguage, "en-US"); - assert.equal(channelAfterPatch.autoReplyMode, "default_teammate"); - assert.equal(channelAfterPatch.defaultTeammateId, "teammate_builtin_engineer"); - - const unsupported = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ privacyRules: ["Do not expose private teammate chat."] }), - }); - assert.equal(unsupported.status, 400); - const unsupportedBody = await unsupported.json(); - assert.equal(unsupportedBody.error, "unsupported_protocol_field"); - assert.equal(unsupportedBody.field, "privacyRules"); - - const autoRoute = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ autoReplyMode: "auto_route" }), - }); - assert.equal(autoRoute.status, 400); - assert.equal((await autoRoute.json()).error, "auto_route_disabled"); - - const missingTeammate = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultTeammateId: "missing_teammate" }), - }); - assert.equal(missingTeammate.status, 400); - assert.equal((await missingTeammate.json()).error, "default_teammate_not_found"); - - const reverted = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/protocol/revert?cwd=${encodeURIComponent(cwd)}`, {}); - assert.deepEqual(reverted.revertedFields, ["responseLanguage", "autoReplyMode", "defaultTeammateId", "defaultWorkflowPreference", "visibleMultiReplyMaxParticipants", "privacyRules", "reviewRules"]); - assert.equal(reverted.protocol.responseLanguage, "zh-CN"); - assert.equal(reverted.sources.responseLanguage.source, "workspace_setting"); - assert.equal(reverted.protocol.autoReplyMode, "mentions_only"); - assert.equal(reverted.sources.autoReplyMode.source, "default"); - assert.equal(reverted.protocol.defaultTeammateId, undefined); - const channelAfterRevert = (await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`)).channel; - assert.equal(channelAfterRevert.responseLanguage, undefined); - assert.equal(channelAfterRevert.autoReplyMode, "mentions_only"); - assert.equal(channelAfterRevert.defaultTeammateId, undefined); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel collaboration protocol drafts apply protocol-only overrides with source refs", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-drafts-")); - const daemon = await startDaemonForTest(); - try { - const first = await createChannel(daemon.url, cwd); - const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Draft a protocol update.", - }); - const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Wrong channel source.", - }); - - const created = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { - baseVersion: 0, - proposedPatch: { - defaultWorkflowPreference: "architect_engineer_qa", - visibleMultiReplyMaxParticipants: 2, - privacyRules: ["Do not expose private teammate chat."], - reviewRules: ["Require QA evidence before Done."], - }, - reason: "AI suggested a tighter channel protocol.", - createdBy: "ai-draft", - sourceRefs: [{ type: "message", id: message.message.id, label: "Source request" }], - }); - assert.equal(created.draft.status, "draft"); - assert.equal(created.draft.baseVersion, 0); - assert.equal(created.protocol.defaultWorkflowPreference, "manual"); - assert.equal(created.sources.defaultWorkflowPreference.source, "default"); - - let drafts = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}&status=draft`); - assert.deepEqual(drafts.drafts.map((draft) => draft.id), [created.draft.id]); - - const applied = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts/${encodeURIComponent(created.draft.id)}/apply?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(applied.draft.status, "applied"); - assert.equal(applied.draft.appliedProtocolVersion, 1); - assert.equal(applied.protocol.protocolVersion, 1); - assert.equal(applied.protocol.defaultWorkflowPreference, "architect_engineer_qa"); - assert.equal(applied.sources.defaultWorkflowPreference.source, "protocol_override"); - assert.equal(applied.protocol.visibleMultiReplyMaxParticipants, 2); - assert.deepEqual(applied.protocol.privacyRules, ["Do not expose private teammate chat."]); - assert.deepEqual(applied.protocol.reviewRules, ["Require QA evidence before Done."]); - - const stale = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { - baseVersion: 0, - proposedPatch: { visibleMultiReplyMaxParticipants: 3 }, - reason: "Stale update.", - }); - const staleApply = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts/${encodeURIComponent(stale.draft.id)}/apply?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - }); - assert.equal(staleApply.status, 409); - const staleBody = await staleApply.json(); - assert.equal(staleBody.error, "draft_base_version_mismatch"); - assert.equal(staleBody.draft.status, "stale"); - - const crossRef = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - proposedPatch: { visibleMultiReplyMaxParticipants: 4 }, - reason: "Cross-channel source should fail.", - sourceRefs: [{ type: "message", id: otherMessage.message.id }], - }), - }); - assert.equal(crossRef.status, 400); - assert.match((await crossRef.json()).message, /channel/i); - - const reverted = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/revert?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(reverted.protocol.protocolVersion, undefined); - assert.equal(reverted.protocol.defaultWorkflowPreference, "manual"); - assert.equal(reverted.sources.defaultWorkflowPreference.source, "default"); - drafts = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol/drafts?cwd=${encodeURIComponent(cwd)}&status=applied`); - assert.equal(drafts.drafts.some((draft) => draft.id === created.draft.id), true); - - const candidate = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}`, { - baseVersion: 0, - proposedPatch: { reviewRules: ["Keep candidate review separate from skill creation."] }, - reason: "Channel canvas candidate.", - sourceRefs: [{ type: "message", id: message.message.id, label: "Canvas source" }], - }); - assert.equal(candidate.draft.status, "draft"); - const candidates = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}&status=draft&limit=5`); - assert.equal(candidates.drafts.some((draft) => draft.id === candidate.draft.id), true); - - const rejected = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates/${encodeURIComponent(candidate.draft.id)}/review?cwd=${encodeURIComponent(cwd)}`, { - action: "reject", - reason: "Not needed now.", - }); - assert.equal(rejected.draft.status, "cancelled"); - assert.equal(rejected.protocol.protocolVersion, undefined); - - const approvedCandidate = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates?cwd=${encodeURIComponent(cwd)}`, { - baseVersion: 0, - proposedPatch: { privacyRules: ["Do not turn every channel protocol into a skill."] }, - reason: "Approved channel canvas candidate.", - sourceRefs: [{ type: "message", id: message.message.id, label: "Canvas source" }], - }); - const reviewed = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/protocol-candidates/${encodeURIComponent(approvedCandidate.draft.id)}/review?cwd=${encodeURIComponent(cwd)}`, { - action: "approve", - reason: "Matches channel canvas policy.", - }); - assert.equal(reviewed.draft.status, "applied"); - assert.equal(reviewed.protocol.protocolVersion, 1); - assert.deepEqual(reviewed.protocol.privacyRules, ["Do not turn every channel protocol into a skill."]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel collaboration protocol endpoints persist state and reject cross-channel sources", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-protocol-api-")); - const daemon = await startDaemonForTest(); - try { - const first = await createChannel(daemon.url, cwd); - const second = await createChannel(daemon.url, cwd); - const message = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Use manual handoffs for v1.1.", - }); - const otherMessage = await postJson(`${daemon.url}/channels/${encodeURIComponent(second.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Other channel.", - }); - const task = (await postJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`, { - channelId: first.id, - title: "Implement protocol", - prompt: "Build channel protocol state.", - })).task; - - const emptyBrief = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/brief?cwd=${encodeURIComponent(cwd)}`); - assert.equal(emptyBrief.brief.objective, ""); - - const decision = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { - sourceMessageId: message.message.id, - title: "Manual handoffs", - decision: "Handoffs do not change task state.", - createdBy: "local-user", - }); - const superseded = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { - title: "Manual handoffs update", - decision: "Supersede by creating a new active decision.", - supersedesDecisionId: decision.decision.id, - createdBy: "local-user", - }); - assert.equal(superseded.decision.status, "active"); - const decisions = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/decisions?cwd=${encodeURIComponent(cwd)}`); - assert.equal(decisions.decisions.find((item) => item.id === decision.decision.id).status, "superseded"); - - const brief = await patchJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/brief?cwd=${encodeURIComponent(cwd)}`, { - objective: "Ship channel v1.1", - scope: "Collaboration protocol", - acceptanceCriteria: ["Brief persists"], - decisionIds: [superseded.decision.id], - updatedBy: "local-user", - }); - assert.deepEqual(brief.brief.acceptanceCriteria, ["Brief persists"]); - - const handoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { - sourceTaskId: task.id, - fromParticipantId: "tian", - fromParticipantLabel: "Tian", - toParticipantId: "engineer", - toParticipantLabel: "Engineer", - reason: "Need implementation", - requiredInput: "Plan", - expectedOutput: "Patch", - createdBy: "local-user", - }); - await patchJson(`${daemon.url}/handoffs/${encodeURIComponent(handoff.handoff.id)}?cwd=${encodeURIComponent(cwd)}`, { status: "completed" }); - const listedTask = (await getJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`)).tasks.find((item) => item.id === task.id); - assert.equal(listedTask.status, "todo"); - - const summary = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`, { - targetType: "task", - targetId: task.id, - sourceMessageIds: [message.message.id], - decisionIds: [superseded.decision.id], - summary: "Build the protocol layer.", - authorType: "local-user", - }); - assert.equal(summary.contextSummary.targetId, task.id); - - const crossSummary = await fetch(`${daemon.url}/channels/${encodeURIComponent(first.id)}/context-summaries?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - targetType: "thread", - targetId: otherMessage.message.id, - sourceMessageIds: [otherMessage.message.id], - summary: "Should fail.", - authorType: "local-user", - }), - }); - assert.equal(crossSummary.status, 400); - assert.match((await crossSummary.json()).message, /channel/i); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("concurrent decision supersession accepts one successor, reuses exact retries, and rejects conflicts", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-decision-supersession-api-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createChannel(daemon.url, cwd); - const source = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/decisions?cwd=${encodeURIComponent(cwd)}`, { - title: "Original decision", - decision: "Use protocol A.", - createdBy: "local-user", - }); - const url = `${daemon.url}/channels/${encodeURIComponent(channel.id)}/decisions?cwd=${encodeURIComponent(cwd)}`; - const replacementA = { - title: "Replacement decision", - decision: "Use protocol B.", - rationale: "B is the verified replacement.", - supersedesDecisionId: source.decision.id, - createdBy: "local-user", - }; - const replacementB = { - ...replacementA, - decision: "Use conflicting protocol C.", - }; - const supersede = (body) => fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - for (const unsupportedStatus of ["active", "superseded"]) { - const unsupportedPatch = await fetch(`${daemon.url}/decisions/${encodeURIComponent(source.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: unsupportedStatus }), - }); - assert.equal(unsupportedPatch.status, 400); - const unsupportedPatchBody = await unsupportedPatch.json(); - assert.equal(unsupportedPatchBody.error, "bad_request"); - assert.match(unsupportedPatchBody.message, /status must be rejected/u); - } - - const invalidStatus = await supersede({ - ...replacementA, - status: "rejected", - }); - assert.equal(invalidStatus.status, 400); - const invalidStatusBody = await invalidStatus.json(); - assert.equal(invalidStatusBody.error, "bad_request"); - assert.match(invalidStatusBody.message, /status must be active/u); - - const rejectable = await postJson(url, { - title: "Decision to reject", - decision: "Do not keep this decision.", - createdBy: "local-user", - }); - const activeReject = await fetch(`${daemon.url}/decisions/${encodeURIComponent(rejectable.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "rejected" }), - }); - assert.equal(activeReject.status, 200); - assert.equal((await activeReject.json()).decision.status, "rejected"); - - const attempts = await Promise.all([supersede(replacementA), supersede(replacementB)]); - assert.deepEqual(attempts.map((attempt) => attempt.status).sort(), [201, 409]); - const createdResponse = attempts.find((attempt) => attempt.status === 201); - const conflictResponse = attempts.find((attempt) => attempt.status === 409); - const created = await createdResponse.json(); - const conflict = await conflictResponse.json(); - assert.equal(conflict.error, "decision_supersession_conflict"); - - const retry = await supersede(created.decision.decision === replacementA.decision ? replacementA : replacementB); - assert.equal(retry.status, 200); - assert.equal((await retry.json()).decision.id, created.decision.id); - - const staleReject = await fetch(`${daemon.url}/decisions/${encodeURIComponent(source.decision.id)}?cwd=${encodeURIComponent(cwd)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "rejected" }), - }); - assert.equal(staleReject.status, 409); - const staleRejectBody = await staleReject.json(); - assert.equal(staleRejectBody.error, "decision_status_conflict"); - assert.equal(staleRejectBody.decisionId, source.decision.id); - assert.equal(staleRejectBody.expectedStatus, "active"); - assert.equal(staleRejectBody.actualStatus, "superseded"); - - const decisions = await getJson(`${url}&limit=10`); - assert.equal(decisions.decisions.find((decision) => decision.id === source.decision.id).status, "superseded"); - const successors = decisions.decisions.filter((decision) => decision.supersedesDecisionId === source.decision.id); - assert.equal(successors.length, 1); - assert.equal(successors[0].status, "active"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel handoff workflows start steps, create tasks, and expose blockers", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflows-api-")); - const daemon = await startDaemonForTest(); - try { - const first = await createChannel(daemon.url, cwd); - const second = await createChannel(daemon.url, cwd); - const task = (await postJson(`${daemon.url}/tasks?cwd=${encodeURIComponent(cwd)}`, { - channelId: first.id, - title: "Implement workflow", - prompt: "Build v1.2.", - })).task; - const handoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { - sourceTaskId: task.id, - fromParticipantId: "tian", - fromParticipantLabel: "Tian", - toParticipantId: "engineer", - toParticipantLabel: "Engineer", - reason: "Need implementation", - requiredInput: "v1.2 plan", - expectedOutput: "Patch", - createdBy: "local-user", - }); - - const workflowFromHandoffUrl = `${daemon.url}/handoffs/${encodeURIComponent(handoff.handoff.id)}/workflow?cwd=${encodeURIComponent(cwd)}`; - const createFromHandoff = async () => { - const response = await fetch(workflowFromHandoffUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ createdBy: "local-user" }), - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return { status: response.status, payload: await response.json() }; - }; - const attempts = await Promise.all([createFromHandoff(), createFromHandoff()]); - assert.deepEqual(attempts.map((attempt) => attempt.status).sort(), [200, 201]); - const createdWorkflow = attempts.find((attempt) => attempt.status === 201).payload; - const retriedWorkflow = attempts.find((attempt) => attempt.status === 200).payload; - assert.equal(createdWorkflow.workflow.status, "draft"); - assert.equal(createdWorkflow.workflowStep.status, "queued"); - assert.equal(retriedWorkflow.workflow.id, createdWorkflow.workflow.id); - assert.equal(retriedWorkflow.workflowStep.id, createdWorkflow.workflowStep.id); - const handoffWorkflows = await getJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/workflows?cwd=${encodeURIComponent(cwd)}&limit=10`); - assert.deepEqual(handoffWorkflows.workflows.map((workflow) => workflow.id), [createdWorkflow.workflow.id]); - - const started = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(createdWorkflow.workflowStep.id)}/start?cwd=${encodeURIComponent(cwd)}`, { - mode: "create_task", - taskTitle: "Workflow generated task", - }); - assert.equal(started.status, "started"); - assert.equal(started.mode, "create_task"); - assert.equal(started.workflow.status, "succeeded"); - assert.equal(started.workflowStep.status, "succeeded"); - assert.equal(started.task.title, "Workflow generated task"); - assert.equal(started.task.assigneeTeammateId, "engineer"); - - const workflowEvents = await getJson(`${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=workflow&entityId=${encodeURIComponent(createdWorkflow.workflow.id)}`); - assert.ok(workflowEvents.transitions.some((event) => event.toStatus === "succeeded")); - const stepEvents = await getJson(`${daemon.url}/state-transitions?cwd=${encodeURIComponent(cwd)}&entityType=workflow_step&entityId=${encodeURIComponent(createdWorkflow.workflowStep.id)}`); - assert.ok(stepEvents.transitions.some((event) => event.toStatus === "succeeded")); - - const secondHandoff = await postJson(`${daemon.url}/channels/${encodeURIComponent(first.id)}/handoffs?cwd=${encodeURIComponent(cwd)}`, { - sourceTaskId: task.id, - fromParticipantId: "tian", - fromParticipantLabel: "Tian", - toParticipantId: "engineer", - toParticipantLabel: "Engineer", - reason: "Need run", - requiredInput: "v1.2 plan", - expectedOutput: "Run", - createdBy: "local-user", - }); - simulateLegacyChannelWithoutModel(cwd, first.id); - const blockedWorkflow = await postJson(`${daemon.url}/handoffs/${encodeURIComponent(secondHandoff.handoff.id)}/workflow?cwd=${encodeURIComponent(cwd)}`, { - createdBy: "local-user", - }); - const blocked = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(blockedWorkflow.workflowStep.id)}/start?cwd=${encodeURIComponent(cwd)}`, { - mode: "create_and_run_task", - }); - assert.equal(blocked.status, "blocked"); - assert.equal(blocked.mode, "create_and_run_task"); - assert.equal(blocked.workflowStep.status, "blocked"); - assert.match(blocked.blocker, /provider preset/i); - - const crossWorkflow = await fetch(`${daemon.url}/channels/${encodeURIComponent(second.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sourceHandoffId: handoff.handoff.id, - title: "Cross channel", - objective: "Should fail.", - createdBy: "local-user", - }), - }); - assert.equal(crossWorkflow.status, 400); - assert.match((await crossWorkflow.json()).message, /channel/i); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("manual workflow step start rejects a result after source context changes", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-manual-workflow-stale-context-")); - const runner = new DelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Manual continuity workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const store = stores.get(cwd); - assert.ok(store); - store.updateChannel(channel.id, { defaultProviderPresetId: provider.id }, "2026-08-10T03:00:00.000Z"); - const rootThread = store.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: channel.id, - title: "Manual continuity workflow", - objective: "Implement the original continuity objective.", - }, "2026-08-10T03:00:00.000Z").thread; - const sourceItem = store.appendConversationUserTurn({ - channelId: channel.id, - threadId: rootThread.id, - title: "Implement the original continuity objective.", - userInstruction: "Implement the original continuity objective.", - payload: { - body: "Implement the original continuity objective.", - format: "plain_text", - mentions: [], - attachmentIds: [], - linkPreviewIds: [], - fileRefs: [], - }, - idempotencyKey: "manual-workflow-stale-context-source", - }, "2026-08-10T03:00:00.000Z").item; - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Manual continuity workflow", - objective: "Implement the original continuity objective.", - status: "ready", - createdBy: "local-user", - metadata: { sourceMessageId: sourceItem.id }, - }, "2026-08-10T03:00:01.000Z"); - const step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - title: "Manual continuity step", - prompt: "Implement the original continuity objective.", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - metadata: { - sourceMessageId: sourceItem.id, - providerPresetId: provider.id, - requiredTools: ["read"], - }, - }, "2026-08-10T03:00:02.000Z"); - - const response = await fetch(`${daemon.url}/workflow-steps/${encodeURIComponent(step.id)}/start?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode: "create_and_run_task" }), - }); - assert.equal(response.ok, true); - await waitForCondition(() => runner.runs.length === 1, "manual workflow runner start"); - store.appendConversationItem({ - channelId: channel.id, - threadId: sourceItem.threadId, - turnId: sourceItem.turnId, - kind: "user_message", - status: "committed", - actorType: "local_user", - visibility: "channel", - payload: { ...sourceItem.payload, body: "Replace the objective before the manual result commits." }, - supersedesItemId: sourceItem.id, - idempotencyKey: "manual-workflow-stale-context-correction", - }, "2026-08-10T03:00:03.000Z"); - runner.resolve("This result consumed the old objective."); - await response.text(); - - const blockedStep = store.getChannelWorkflowStep(step.id); - assert.equal(blockedStep.status, "blocked"); - assert.ok(blockedStep.taskId); - assert.equal(blockedStep.resultSummary, undefined); - assert.equal(blockedStep.metadata.resultContextReview.status, "rejected_stale"); - assert.equal(store.getChannelWorkflow(workflow.id).status, "blocked"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("queued workflow runs keep private role memory out of durable snapshots", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-queued-workflow-memory-isolation-")); - const runner = new AbortableDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Queued continuity workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd)).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const store = stores.get(cwd); - assert.ok(store); - store.updateChannel(channel.id, { defaultProviderPresetId: provider.id }, "2026-08-10T03:10:00.000Z"); - - await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/turns?cwd=${encodeURIComponent(cwd)}`, { - body: "Hold the channel runtime slot.", - requestAiReply: true, - providerPresetId: provider.id, - }); - await waitForCondition(() => runner.runs.length === 1, "blocking channel reply start"); - const blockingRun = store.listRuns({ limit: 20 }).find((run) => run.status === "running"); - assert.ok(blockingRun); - - const privateMemory = store.createMemoryObject({ - workspaceId: channel.workspaceId, - scopeType: "teammate", - ownerId: "ling_agent", - title: "Queued continuity objective", - body: "Queued continuity objective private role evidence must stay owner-scoped.", - tags: ["queued-continuity"], - sourceRefs: [], - reviewState: "active", - enabled: true, - sensitivity: "private", - verifyBeforeUse: false, - createdBy: "test", - }, "2026-08-10T03:10:01.000Z"); - const rootThread = store.resolveOrCreateConversationThread({ - surfaceKind: "channel_root", - channelId: channel.id, - title: "Queued continuity workflow", - objective: "Queued continuity objective", - }, "2026-08-10T03:10:02.000Z").thread; - const sourceItem = store.appendConversationUserTurn({ - channelId: channel.id, - threadId: rootThread.id, - title: "Queued continuity objective", - userInstruction: "Queued continuity objective", - payload: { - body: "Queued continuity objective", - format: "plain_text", - mentions: [], - attachmentIds: [], - linkPreviewIds: [], - fileRefs: [], - }, - idempotencyKey: "queued-workflow-memory-source", - }, "2026-08-10T03:10:02.000Z").item; - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Queued continuity workflow", - objective: "Queued continuity objective", - status: "ready", - createdBy: "local-user", - metadata: { sourceMessageId: sourceItem.id }, - }, "2026-08-10T03:10:03.000Z"); - const step = store.createChannelWorkflowStep({ - workflowId: workflow.id, - title: "Queued continuity objective", - prompt: "Queued continuity objective", - toParticipantId: "ling_agent", - toParticipantLabel: "Ling Agent", - metadata: { - sourceMessageId: sourceItem.id, - providerPresetId: provider.id, - requiredTools: ["read"], - }, - }, "2026-08-10T03:10:04.000Z"); - - const response = await fetch(`${daemon.url}/workflow-steps/${encodeURIComponent(step.id)}/start?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode: "create_and_run_task" }), - signal: AbortSignal.timeout(10_000), - }); - const queuedResponse = await response.text(); - assert.equal(response.ok, true, queuedResponse); - - const queuedRun = store.listRuns({ status: "queued", limit: 20 }) - .find((run) => run.workflowStepId === step.id); - assert.ok(queuedRun); - assert.equal(queuedRun.requestSnapshot.workflowStepId, step.id); - const queuedStep = store.getChannelWorkflowStep(step.id); - assert.equal(queuedStep.status, "running"); - assert.equal(queuedStep.runId, queuedRun.id); - assert.equal(store.getChannelWorkflow(workflow.id).status, "running"); - assert.doesNotMatch(queuedRun.taskSnapshot.prompt, /private role evidence/u); - assert.doesNotMatch(queuedRun.requestSnapshot.task.prompt, /private role evidence/u); - assert.equal(queuedRun.requestSnapshot.preselectedMemoryUse, undefined); - const workUnit = store.getWorkUnit(queuedRun.workUnitId); - assert.ok(workUnit); - assert.doesNotMatch(workUnit.request.task.prompt, /private role evidence/u); - assert.equal(workUnit.request.preselectedMemoryUse, undefined); - assert.equal( - store.listUsedMemoryEvents({ runId: queuedRun.id, limit: 20 }).some((event) => event.memoryIds.includes(privateMemory.id)), - false, - ); - - runner.resolve("The blocking channel reply is complete."); - await waitForCondition(() => store.getRun(blockingRun.id).status === "succeeded", "blocking channel reply completion"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow handoff intents validate, accept, and stay visible as workflow evidence", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-handoff-intents-")); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const store = stores.get(cwd); - assert.ok(store); - const now = new Date().toISOString(); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Handoff intent workflow", - objective: "Validate workflow handoff intent evidence.", - status: "ready", - createdBy: "local-user", - }, now); - const sourceStep = createParallelWorkflowStep(store, workflow.id, "Source Step", { - branchGroupId: "source", - }); - - const created = await postJson(`${daemon.url}/workflow-handoff-intents?cwd=${encodeURIComponent(cwd)}`, { - channelId: channel.id, - workflowId: workflow.id, - sourceWorkflowStepId: sourceStep.id, - fromParticipantId: "teammate_builtin_architect", - fromParticipantLabel: "Architect", - toParticipantId: "teammate_builtin_engineer", - toParticipantLabel: "Engineer", - reason: "Engineer should implement the validated boundary.", - requiredInput: "Design boundary", - expectedOutput: "Patch", - riskSummary: "No permission is granted by this packet.", - sourceRefs: [ - { type: "workflow", id: workflow.id, label: workflow.title }, - { type: "workflow_step", id: sourceStep.id, label: sourceStep.title }, - ], - createdBy: "orchestrator", - }); - assert.equal(created.handoffIntent.status, "validated"); - assert.equal(created.handoffIntent.validation.targetTeammateExists, true); - assert.equal(created.handoffIntent.validation.targetTeammateActive, true); - - const bypassResponse = await fetch( - `${daemon.url}/workflow-handoff-intents/${encodeURIComponent(created.handoffIntent.id)}?cwd=${encodeURIComponent(cwd)}`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: "accepted" }), - }, - ); - assert.equal(bypassResponse.status, 400); - assert.match(await bypassResponse.text(), /status is server-owned/u); - assert.equal(store.getWorkflowHandoffIntent(created.handoffIntent.id).status, "validated"); - - const stepAfterIntent = store.getChannelWorkflowStep(sourceStep.id); - assert.ok(stepAfterIntent.metadata.handoffIntentIds.includes(created.handoffIntent.id)); - - const accepted = await postJson(`${daemon.url}/workflow-handoff-intents/${encodeURIComponent(created.handoffIntent.id)}/accept?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(accepted.intent.status, "accepted"); - assert.ok(accepted.intent.channelHandoffId); - - const listed = await getJson(`${daemon.url}/workflow-handoff-intents?cwd=${encodeURIComponent(cwd)}&workflowId=${encodeURIComponent(workflow.id)}`); - assert.deepEqual(listed.handoffIntents.map((intent) => intent.id), [created.handoffIntent.id]); - - const evidence = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); - assert.equal(evidence.handoffIntents.some((intent) => intent.id === created.handoffIntent.id && intent.status === "accepted"), true); - assert.equal(evidence.handoffs.some((handoff) => handoff.id === accepted.intent.channelHandoffId && handoff.status === "accepted"), true); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow collaboration card endpoints derive compact product cards", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-workflow-collaboration-card-")); - const stores = new Map(); - const daemon = await startDaemonForTest(undefined, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createChannel(daemon.url, cwd); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Ship collaboration card read model", - objective: "Expose a compact collaboration card without raw workflow internals.", - status: "running", - createdBy: "ling_agent", - }, "2026-06-13T00:00:00.000Z"); - const designStep = store.createChannelWorkflowStep({ - workflowId: workflow.id, - position: 1, - title: "Design API boundary", - prompt: "Define the daemon API contract.", - toParticipantId: "teammate_builtin_architect", - toParticipantLabel: "Architect", - status: "succeeded", - metadata: { branchGroupId: "design" }, - }, "2026-06-13T00:01:00.000Z"); - store.updateChannelWorkflowStep(designStep.id, { resultSummary: "Contract boundary is ready." }, "2026-06-13T00:02:00.000Z"); - const implementationStep = store.createChannelWorkflowStep({ - workflowId: workflow.id, - position: 2, - title: "Implement endpoint", - prompt: "Implement the daemon endpoint and client facade.", - toParticipantId: "teammate_builtin_engineer", - toParticipantLabel: "Engineer", - status: "waiting_for_approval", - metadata: { branchGroupId: "implementation", maxParallelSiblings: 2 }, - }, "2026-06-13T00:03:00.000Z"); - const run = store.createRun({ - id: "run_collaboration_card_waiting", - cwd, - taskId: "task_collaboration_card_waiting", - channelId: channel.id, - teammateId: "teammate_builtin_engineer", - workflowStepId: implementationStep.id, - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: "task_collaboration_card_waiting", title: "Implement endpoint" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { cwd }, - createdAt: "2026-06-13T00:04:00.000Z", - }); - store.markRunWaitingForApproval(run.id, "2026-06-13T00:05:00.000Z"); - store.updateChannelWorkflowStep(implementationStep.id, { runId: run.id }, "2026-06-13T00:05:00.000Z"); - store.createApprovalRequest({ - runId: run.id, - taskId: run.taskId, - channelId: channel.id, - actionType: "write", - risk: "medium", - title: "Approve endpoint edit", - description: "Allow editing daemon endpoint files.", - payload: { type: "file_write", path: `${cwd}/packages/daemon/src/features/channels/collaboration-cards.ts`, bytes: 128 }, - expiresAt: "2999-01-01T00:00:00.000Z", - }, "2026-06-13T00:06:00.000Z"); - - const listed = await getJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/collaboration-cards?cwd=${encodeURIComponent(cwd)}`); - assert.equal(listed.channelId, channel.id); - assert.equal(listed.cards.length, 1); - const card = listed.cards[0]; - assert.equal(card.workflowId, workflow.id); - assert.equal(card.mode, "waiting"); - assert.equal(card.executionMode, "parallel"); - assert.equal(card.currentOwnerLabel, "Engineer"); - assert.equal(card.pendingApprovalCount, 1); - assert.equal(card.completedStepCount, 1); - assert.equal(card.totalStepCount, 2); - assert.equal(card.branches.length, 2); - assert.equal(card.branches.some((branch) => branch.ownerLabels.includes("Architect")), true); - assert.equal(card.branches.some((branch) => branch.ownerLabels.includes("Engineer")), true); - assert.equal(JSON.stringify(card).includes("selector"), false); - assert.equal(JSON.stringify(card).includes("contextPack"), false); - - const single = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/collaboration-card?cwd=${encodeURIComponent(cwd)}`); - assert.equal(single.workflowId, workflow.id); - assert.deepEqual(single.card, card); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("autoplan-lite workflow compiles durable steps and orchestrates them through the runner", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-orchestrator-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Autoplan", - status: "active", - boundPath: cwd, - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Build the orchestrator test slice.", - providerPresetId: provider.id, - runnerId: "pi", - createdBy: "local-user", - }); - assert.equal(compiled.workflow.status, "ready"); - assert.equal(compiled.workflowSteps.length, 8); - assert.equal(compiled.workflowSteps[0].metadata.templateStepId, "product-discovery"); - assert.equal(compiled.workflowSteps[0].metadata.requestedRunnerId, "pi"); - assert.equal(compiled.workflowSteps[0].metadata.policy.onRunFailed, "block_for_user"); - assert.equal(compiled.workflowSteps[4].metadata.policy.onRunFailed, "fail_workflow"); - assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds.length, 1); - const firstStepMemory = (await postJson(`${daemon.url}/memory?cwd=${encodeURIComponent(cwd)}`, { - scopeType: "teammate", - ownerId: compiled.workflowSteps[0].toParticipantId, - title: "Orchestrator test objective", - body: "Build the orchestrator test slice with source-linked evidence.", - sensitivity: "private", - })).memory; - - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - assert.equal(firstTick.workflowStep.metadata.templateStepId, "product-discovery"); - const stepsAfterFirstTick = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - const firstStepAfterFirstTick = stepsAfterFirstTick.workflowSteps.find((step) => step.id === firstTick.workflowStep.id); - assert.ok(firstStepAfterFirstTick); - assert.ok(firstStepAfterFirstTick.metadata.contextArtifactId); - const firstContext = await getArtifactJson(daemon.url, cwd, firstStepAfterFirstTick.metadata.contextArtifactId); - assert.equal(firstContext.schemaVersion, 1); - assert.equal(firstContext.workflowId, compiled.workflow.id); - assert.equal(firstContext.workflowStepId, firstStepAfterFirstTick.id); - assert.equal(firstContext.objective, "Build the orchestrator test slice."); - assert.deepEqual(firstContext.dependencies, []); - assert.equal(firstContext.providerPresetId, provider.id); - assert.equal(firstContext.contextPack.kind, "workflow_step"); - assert.equal(firstContext.contextPack.providerPresetId, provider.id); - assert.equal(firstContext.contextPack.budget.mode, "model_estimated"); - assert.equal(firstContext.contextPack.budget.capabilitySource, "provider_metadata"); - assert.equal(firstContext.contextPack.budget.capabilityConfidence, "estimated"); - assert.equal(firstContext.contextPack.overBudget, false); - assert.equal(firstContext.contextPack.sections.some((section) => section.id === "workflow_runtime_boundary"), true); - assert.equal(firstContext.contextPack.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); - const workflowEvidenceAfterFirstTick = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); - const firstStepEvidence = workflowEvidenceAfterFirstTick.steps.find((item) => item.step.id === firstStepAfterFirstTick.id); - assert.ok(firstStepEvidence); - assert.equal(firstStepEvidence.contextInspect.availability, "available"); - assert.equal(firstStepEvidence.contextInspect.contextArtifactId, firstStepAfterFirstTick.metadata.contextArtifactId); - assert.equal(firstStepEvidence.contextInspect.kind, "workflow_step"); - assert.equal(firstStepEvidence.contextInspect.budget.mode, "model_estimated"); - assert.equal(firstStepEvidence.contextInspect.budget.capabilitySource, "provider_metadata"); - assert.equal(firstStepEvidence.contextInspect.budget.capabilityConfidence, "estimated"); - assert.equal(firstStepEvidence.contextInspect.sections.some((section) => section.id === "workflow_runtime_boundary"), true); - assert.equal(firstStepEvidence.contextInspect.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); - assert.equal(firstStepEvidence.contextInspect.safety.rawPromptTextIncluded, false); - assert.doesNotMatch(JSON.stringify(firstStepEvidence.contextInspect), /Workflow step runtime boundary:/); - assert.ok(firstStepAfterFirstTick.runId); - const firstRun = await getJson(`${daemon.url}/runs/${encodeURIComponent(firstStepAfterFirstTick.runId)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(firstRun.workflowStepId, firstStepAfterFirstTick.id); - assert.equal(firstRun.requestSnapshot.workflowStepId, firstStepAfterFirstTick.id); - assert.equal(firstRun.requestSnapshot.runnerResolution.requestSource, "workflow_step"); - assert.match(runner.runs[0].appendSystemPrompt?.join("\n") ?? "", /Build the orchestrator test slice with source-linked evidence/u); - // Public snapshots intentionally omit the private workflow prompt. Keep - // this assertion resilient to the field being absent rather than reading - // the private prompt through an undefined public property. - assert.doesNotMatch(JSON.stringify(firstRun.taskSnapshot), /source-linked evidence/u); - assert.doesNotMatch(JSON.stringify(firstRun.requestSnapshot), /source-linked evidence/u); - assert.equal(firstRun.requestSnapshot.preselectedMemoryUse, undefined); - const firstWorkUnit = await getJson(`${daemon.url}/work-units/${encodeURIComponent(firstRun.workUnitId)}?cwd=${encodeURIComponent(cwd)}`); - assert.doesNotMatch(JSON.stringify(firstWorkUnit.workUnit.request), /source-linked evidence/u); - assert.equal(firstWorkUnit.workUnit.request.preselectedMemoryUse, undefined); - const firstRunMemoryUse = await getJson(`${daemon.url}/memory/used?cwd=${encodeURIComponent(cwd)}&runId=${encodeURIComponent(firstRun.id)}`); - assert.deepEqual(firstRunMemoryUse.events[0].memoryIds, [firstStepMemory.id]); - assert.match(firstRunMemoryUse.events[0].policyVersion, /memory_use_isolation_v2/u); - const runsForStep = await getJson(`${daemon.url}/runs?cwd=${encodeURIComponent(cwd)}&workflowStepId=${encodeURIComponent(firstStepAfterFirstTick.id)}`); - assert.deepEqual(runsForStep.runs.map((run) => run.id), [firstRun.id]); - const threadsForStep = await getJson(`${daemon.url}/threads?cwd=${encodeURIComponent(cwd)}&workflowStepId=${encodeURIComponent(firstStepAfterFirstTick.id)}`); - assert.equal(threadsForStep.threads.length, 1); - assert.equal(threadsForStep.threads[0].kind, "workflow_step"); - assert.equal(threadsForStep.threads[0].workflowStepId, firstStepAfterFirstTick.id); - assert.equal(threadsForStep.threads[0].lastRunId, firstRun.id); - - await waitForCondition(() => runner.runs.length === 4, "autoplan-lite planning runner sequence"); - const implementationTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "implementation"); - assert.equal(implementationTick.status, "started", JSON.stringify(implementationTick)); - assert.equal(implementationTick.workflowStep.metadata.templateStepId, "implementation"); - - await waitForCondition(() => runner.runs.length === 6, "autoplan-lite implementation and review sequence"); - const qaTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "qa"); - assert.equal(qaTick.status, "started"); - assert.equal(qaTick.workflowStep.metadata.templateStepId, "qa"); - - await waitForCondition(() => runner.runs.length === 7, "autoplan-lite qa sequence"); - const shipTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "ship"); - assert.equal(shipTick.status, "started"); - assert.equal(shipTick.workflowStep.metadata.templateStepId, "ship"); - - await waitForCondition(() => runner.runs.length === 8, "autoplan-lite runner sequence"); - const finalWorkflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(finalWorkflow.workflow.status, "succeeded"); - const finalSteps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual(finalSteps.workflowSteps.map((step) => step.status), Array(8).fill("succeeded")); - const productStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "product-discovery"); - assert.ok(productStep); - assert.ok(productStep.metadata.outputArtifactId); - assert.ok(productStep.metadata.outputArtifactIds.includes(productStep.metadata.outputArtifactId)); - const productOutput = await getArtifactJson(daemon.url, cwd, productStep.metadata.outputArtifactId); - assert.equal(productOutput.schemaVersion, 1); - assert.equal(productOutput.workflowStepId, productStep.id); - assert.equal(productOutput.runId, productStep.runId); - assert.equal(productOutput.status, "succeeded"); - assert.ok(productOutput.artifactIds.includes(productStep.metadata.contextArtifactId)); - assert.equal(productOutput.runnerAudit.effectiveRunnerId, "pi"); - const designStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "design-review"); - assert.ok(designStep); - assert.ok(designStep.metadata.outputArtifactId); - const implementationStep = finalSteps.workflowSteps.find((step) => step.metadata.templateStepId === "implementation"); - assert.ok(implementationStep); - assert.ok(implementationStep.metadata.contextArtifactId); - const implementationContext = await getArtifactJson(daemon.url, cwd, implementationStep.metadata.contextArtifactId); - assert.equal(implementationContext.workflowStepId, implementationStep.id); - assert.equal(implementationContext.dependencies.length, 2); - assert.equal(implementationContext.dependencies.every((dependency) => dependency.outputArtifactId), true); - assert.equal(implementationContext.priorOutputArtifactIds.length >= 2, true); - assert.match(runner.runs[0].task.prompt, /Context Pack: workflow_step/); - assert.match(runner.runs[0].task.prompt, /Provider budget mode: model_estimated/); - assert.equal(firstRun.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependencies"), true); - assert.equal(firstRun.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_approval_gates"), true); - const implementationRun = runner.runs.find((run) => run.task.title === "Implementation"); - assert.ok(implementationRun); - assert.match(implementationRun.task.prompt, /Context Pack: workflow_step/); - assert.match(implementationRun.task.prompt, /Provider budget mode: model_estimated/); - const implementationRunSnapshot = await getJson(`${daemon.url}/runs/${encodeURIComponent(implementationStep.runId)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependency_1"), true); - assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_dependency_2"), true); - assert.equal(implementationRunSnapshot.requestSnapshot.contextPack.sections.some((section) => section.id === "workflow_approval_gates"), true); - assert.deepEqual(runner.runs.map((run) => run.task.title), [ - "Product Discovery", - "Product Review", - "Design Review", - "Engineering Review", - "Implementation", - "Code Review", - "QA", - "Ship", - ]); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow graph compiles into durable steps and emits standardized packets", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-graph-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Graph workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - workflowGraph: { - schemaVersion: 1, - id: "graph_test", - name: "Graph Test", - summary: "Graph compile test", - entryNodeId: "plan", - nodes: [ - { - id: "plan", - kind: "step", - name: "Plan", - prompt: "Draft the implementation boundary.", - teammateTemplateId: "architect", - skillId: "spec", - autoStart: true, - }, - { - id: "review", - kind: "step", - name: "Review", - prompt: "Review the implementation boundary.", - teammateTemplateId: "engineer", - skillId: "review", - autoStart: true, - }, - ], - edges: [ - { - id: "plan_to_review", - fromNodeId: "plan", - toNodeId: "review", - condition: { kind: "all_succeeded" }, - }, - ], - termination: { - success: "all_required_succeeded", - onFailedRequiredNode: "fail_workflow", - onApprovalDenied: "block_for_user", - }, - maxParallelSteps: 1, - }, - objective: "Ship graph compile test.", - providerPresetId: provider.id, - runnerId: "pi", - createdBy: "local-user", - }); - assert.equal(compiled.workflow.status, "ready"); - assert.equal(compiled.workflow.metadata.workflowGraphId, "graph_test"); - assert.equal(compiled.workflow.metadata.workflowGraphSchemaVersion, 1); - assert.equal(compiled.workflow.metadata.termination.onFailedRequiredNode, "fail_workflow"); - assert.equal(compiled.workflowSteps.length, 2); - assert.deepEqual(compiled.workflowSteps.map((step) => step.metadata.graphNodeId), ["plan", "review"]); - for (const step of compiled.workflowSteps) { - assert.equal(step.participantAuthority.kind, "workflow_definition"); - assert.equal(step.participantAuthority.origin, "workflow_graph"); - assert.equal(step.participantAuthority.sourceId, "graph_test"); - assert.equal(step.participantAuthority.participantId, step.toParticipantId); - } - assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds.length, 1); - assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds[0], compiled.workflowSteps[0].id); - - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - await waitForCondition(() => runner.runs.length === 2, "workflow graph runner sequence"); - - const finalWorkflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(finalWorkflow.workflow.status, "succeeded"); - const finalSteps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - const planStep = finalSteps.workflowSteps.find((step) => step.metadata.graphNodeId === "plan"); - const reviewStep = finalSteps.workflowSteps.find((step) => step.metadata.graphNodeId === "review"); - assert.ok(planStep); - assert.ok(reviewStep); - assert.ok(planStep.metadata.contextArtifactId); - assert.ok(planStep.metadata.outputArtifactId); - assert.ok(reviewStep.metadata.contextArtifactId); - - const planContext = await getArtifactJson(daemon.url, cwd, planStep.metadata.contextArtifactId); - assert.equal(planContext.graphId, "graph_test"); - assert.equal(planContext.graphNodeId, "plan"); - assert.equal(planContext.teammateId, planStep.toParticipantId); - assert.equal(planContext.skillId, "spec"); - assert.equal(planContext.contextPack.kind, "workflow_step"); - assert.equal(planContext.contextPack.sections.some((section) => section.id === "workflow_current_step"), true); - assert.equal(planContext.sourceRefs.some((ref) => ref.type === "workflow" && ref.id === compiled.workflow.id), true); - - const planOutput = await getArtifactJson(daemon.url, cwd, planStep.metadata.outputArtifactId); - assert.equal(planOutput.graphId, "graph_test"); - assert.equal(planOutput.graphNodeId, "plan"); - assert.equal(planOutput.teammateId, planStep.toParticipantId); - assert.equal(planOutput.skillId, "spec"); - assert.ok(Array.isArray(planOutput.blockers)); - assert.ok(Array.isArray(planOutput.changedPaths)); - assert.equal(planOutput.sourceRefs.some((ref) => ref.type === "run" && ref.id === planStep.runId), true); - - const reviewContext = await getArtifactJson(daemon.url, cwd, reviewStep.metadata.contextArtifactId); - assert.equal(reviewContext.dependencies.length, 1); - assert.equal(reviewContext.dependencies[0].workflowStepId, planStep.id); - assert.equal(reviewContext.dependencies[0].outputArtifactId, planStep.metadata.outputArtifactId); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -for (const contextWindowTokens of [1800, 4096]) { -test(`workflow dependency compaction preserves the SDK budget gate at ${contextWindowTokens} tokens`, async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-context-compaction-")); - const runner = new class extends RecordingRunner { - providerRequests = 0; - - registerCustomProviderIfNeeded(runtime, provider) { - super.registerCustomProviderIfNeeded(runtime, provider); - const streamSimple = runtime.streamSimple.bind(runtime); - runtime.streamSimple = (...args) => { - this.providerRequests += 1; - return streamSimple(...args); - }; - } - }(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - contextWindow: contextWindowTokens, - maxTokens: 512, - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Context compaction workflow", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const now = new Date().toISOString(); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Dependency compaction", - objective: "Verify dependency output compaction before the downstream step starts.", - status: "ready", - createdBy: "local-user", - }, now); - const hugeDependencySummary = `Important dependency summary. ${"research finding detail ".repeat(80)}`; - const fullInputBudget = contextWindowTokens - 512 - 512; - // Measured with the production packer: 20 capped dependencies exceed the - // 4K model's 3072-token packet budget; the compacted summary still fits. - const dependencyCount = contextWindowTokens === 1800 ? 7 : 20; - const upstreamSteps = Array.from({ length: dependencyCount }, (_, index) => { - const step = createParallelWorkflowStep(store, workflow.id, `Research Findings ${index + 1}`, { - branchGroupId: `research-${index + 1}`, - providerPresetId: provider.id, - status: "succeeded", - }); - store.updateChannelWorkflowStep( - step.id, - { resultSummary: hugeDependencySummary }, - now, - ); - return step; - }); - const downstream = createParallelWorkflowStep(store, workflow.id, "Implementation From Research", { - branchGroupId: "implementation", - providerPresetId: provider.id, - dependsOnStepIds: upstreamSteps.map((step) => step.id), - dependencyMode: "all_succeeded", - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - if (contextWindowTokens === 1800) { - // The effective input budget is 776 tokens after output and runtime - // reservations. The complete workflow packet cannot fit, so the - // orchestrator must block before creating a run or calling the provider. - assert.equal(tick.status, "blocked"); - assert.equal(runner.runs.length, 0); - const blockedStep = store.getChannelWorkflowStep(downstream.id); - assert.equal(blockedStep?.status, "blocked"); - assert.match(blockedStep?.blocker ?? "", /exceeds provider budget/); - return; - } - assert.equal(tick.status, "started"); - assert.equal(tick.workflowStep.id, downstream.id); - assert.equal(runner.runs.length, 1); - assert.match(runner.runs[0].task.prompt, /Compacted workflow dependency summary/); - assert.match(runner.runs[0].task.prompt, /Compaction id: workflow_packet_summary_/); - assert.doesNotMatch(runner.runs[0].task.prompt, /research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail research finding detail/); - - let startedStep; - try { - startedStep = await waitForWorkflowStepStatus(daemon.url, cwd, workflow.id, downstream.id, - contextWindowTokens === 1800 ? "blocked" : "succeeded"); - } catch { - const step = store.getChannelWorkflowStep(downstream.id); - const run = step?.runId ? store.getRun(step.runId) : undefined; - assert.fail(`Compacted workflow did not settle: ${JSON.stringify({ step: step && { status: step.status, - error: step.errorMessage, runId: step.runId }, run: run && { status: run.status, error: run.errorMessage }, - errors: run ? store.listEvents(run.id).filter(event => event.type === "run_error").map(event => event.data) : [] })}`); - } - const run = store.getRun(startedStep.runId); - if (contextWindowTokens === 1800) { - assert.equal(run?.status, "failed"); - assert.match(run.errorMessage, /Pi model context exceeds selected model budget/); - assert.equal(runner.providerRequests, 0, "packet compaction cannot bypass the SDK's complete-input capacity check"); - } else { - assert.equal(run?.status, "succeeded"); - assert.equal(runner.providerRequests, 1, "the actual SDK must admit the compacted input before the no-network reply"); - } - assert.ok(startedStep.metadata.contextArtifactId); - const contextPacket = await getArtifactJson(daemon.url, cwd, startedStep.metadata.contextArtifactId); - assert.equal(contextPacket.contextPack.kind, "workflow_step"); - assert.equal(contextPacket.contextPack.budget.mode, "model_known"); - assert.equal(contextPacket.contextPack.budget.contextWindowTokens, contextWindowTokens); - assert.equal(contextPacket.contextPack.budget.reservedOutputTokens, 512); - assert.equal(contextPacket.contextPack.budget.reservedRuntimeOverheadTokens, 512); - assert.equal(contextPacket.contextPack.budget.inputBudgetTokens, fullInputBudget); - assert.equal(contextPacket.contextPack.budget.tokenizer, "openai-compatible"); - assert.equal(contextPacket.contextPack.budget.capabilitySource, "user_override"); - assert.equal(contextPacket.contextPack.budget.capabilityConfidence, "known"); - assert.equal(typeof contextPacket.contextPack.estimatedInputTokens, "number"); - assert.equal(contextPacket.contextPack.overBudget, false); - assert.equal(contextPacket.contextPack.sections.some((section) => section.id === "workflow_dependency_summary"), true); - assert.equal(contextPacket.contextPack.sections.some((section) => section.id === "workflow_dependency_1"), false); - assert.equal(contextPacket.contextPack.sections.every((section) => typeof section.estimatedTokens === "number"), true); - const protocolSection = contextPacket.contextPack.sections.find((section) => section.id === "channel_collaboration_protocol"); - assert.ok(protocolSection); - assert.equal(protocolSection.metadata.autoReplyMode, "mentions_only"); - assert.equal(protocolSection.metadata.autoReplyModeSource, "default"); - assert.equal(contextPacket.contextPack.droppedSectionIds.includes("workflow_dependency_summary"), false); - assert.equal(contextPacket.contextPack.compactionIds.length, 1); - const compactionArtifactId = contextPacket.contextPack.compactionIds[0]; - assert.equal(contextPacket.contextPack.sourceRefs.some((ref) => ref.type === "artifact" && ref.id === compactionArtifactId), true); - - const compactionPacket = await getArtifactJson(daemon.url, cwd, compactionArtifactId); - assert.equal(compactionPacket.schemaVersion, 1); - assert.equal(compactionPacket.kind, "workflow_packet_summary"); - assert.equal(compactionPacket.workflowId, workflow.id); - assert.equal(compactionPacket.workflowStepId, downstream.id); - assert.equal(compactionPacket.dependencyOutputRefs.length, upstreamSteps.length); - assert.deepEqual(compactionPacket.dependencyOutputRefs.map((ref) => ref.workflowStepId), upstreamSteps.map((step) => step.id)); - assert.equal(compactionPacket.droppedSectionIds.some((id) => id.startsWith("workflow_dependency_")), true); - assert.equal(compactionPacket.sourceRefs.some((ref) => ref.type === "workflow_step" && ref.id === upstreamSteps[0].id), true); - assert.ok(compactionPacket.summary.includes(`Dependency count: ${upstreamSteps.length}.`)); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); -} - -test("workflow planning failure follows policy and blocks for user repair", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-planning-failure-")); - const runner = new SelectiveFailingRunner(["Product Discovery"]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Planning failure workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Block failed planning for repair.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - const failedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, firstTick.workflowStep.id, "blocked"); - assert.equal(failedStep.metadata.policy.onRunFailed, "block_for_user"); - assert.match(failedStep.blocker, /Product Discovery failed/); - const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflow.workflow.status, "blocked"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow implementation failure follows policy and fails the workflow", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-implementation-failure-")); - const runner = new SelectiveFailingRunner(["Implementation"]); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Implementation failure workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Fail implementation when policy requires it.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - await waitForCondition(() => runner.runs.length === 4, "planning and review steps before implementation failure"); - const implementationTick = await approveWorkflowStepGate(daemon.url, cwd, compiled.workflow.id, "implementation"); - assert.equal(implementationTick.status, "started", JSON.stringify(implementationTick)); - const failedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "failed"); - assert.equal(failedStep.metadata.policy.onRunFailed, "fail_workflow"); - assert.match(failedStep.blocker, /Implementation failed/); - const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflow.workflow.status, "failed"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow post-step gate pauses downstream steps until approval", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Post gate workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Pause after discovery for review.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const store = stores.get(cwd); - assert.ok(store); - const discoveryStep = store.getChannelWorkflowStep(compiled.workflowSteps[0].id); - assert.ok(discoveryStep); - store.updateChannelWorkflowStep( - discoveryStep.id, - { - metadata: { - ...discoveryStep.metadata, - gates: [{ - stage: "post", - approvalProfile: { mode: "standard", risk: "medium" }, - title: "Review discovery output", - reason: "Review Product Discovery output before downstream planning starts.", - requiredForStatuses: ["succeeded"], - }], - }, - }, - new Date().toISOString(), - ); - - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - const gatedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "waiting_for_approval"); - assert.equal(gatedStep.metadata.activeGateStage, "post"); - assert.ok(gatedStep.metadata.postStepApprovalId); - assert.equal(runner.runs.length, 1); - const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(discoveryStep.id)}`); - assert.equal(pending.approvals.length, 1); - assert.equal(pending.approvals[0].payload.gateStage, "post"); - - const waitingTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(waitingTick.status, "waiting_for_approval"); - assert.equal(runner.runs.length, 1); - await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/approve?cwd=${encodeURIComponent(cwd)}`, { - reason: "Discovery output reviewed.", - }); - const discoveryAfterApproval = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "succeeded"); - assert.equal(discoveryAfterApproval.metadata.activeGateStage, undefined); - assert.ok(discoveryAfterApproval.metadata.outputArtifactId); - const workflowAfterApproval = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflowAfterApproval.workflow.status, "running"); - const nextTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(nextTick.status, "started"); - assert.equal(nextTick.workflowStep.metadata.templateStepId, "product-review"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow post-step gate resumes after daemon restart when approval was granted", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-post-gate-restart-")); - const runner = new RecordingRunner(); - const stores = new Map(); - let daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Post gate restart workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Resume a granted post gate after daemon restart.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const store = stores.get(cwd); - assert.ok(store); - const discoveryStep = store.getChannelWorkflowStep(compiled.workflowSteps[0].id); - assert.ok(discoveryStep); - store.updateChannelWorkflowStep( - discoveryStep.id, - { - metadata: { - ...discoveryStep.metadata, - gates: [{ - stage: "post", - approvalProfile: { mode: "standard", risk: "medium" }, - title: "Restart review discovery output", - reason: "Review Product Discovery output before restart recovery continues.", - requiredForStatuses: ["succeeded"], - }], - }, - }, - new Date().toISOString(), - ); - - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - const gatedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "waiting_for_approval"); - assert.equal(gatedStep.metadata.activeGateStage, "post"); - assert.ok(gatedStep.metadata.postStepApprovalId); - const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(discoveryStep.id)}`); - assert.equal(pending.approvals.length, 1); - await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/approve?cwd=${encodeURIComponent(cwd)}`, { - reason: "Discovery output approved before daemon restart.", - }); - - await daemon.close(); - daemon = await startDaemonForTest(runner); - - const resumedTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(resumedTick.status, "started"); - assert.equal(resumedTick.workflowStep.metadata.templateStepId, "product-review"); - const discoveryAfterRestart = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, discoveryStep.id, "succeeded"); - assert.equal(discoveryAfterRestart.metadata.activeGateStage, undefined); - assert.ok(discoveryAfterRestart.metadata.outputArtifactId); - await waitForCondition(() => runner.runs.length >= 2, "post-gate restart downstream runner start"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow run cancellation follows default policy and blocks for user", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancellation-policy-")); - const runner = new DelayedRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Cancellation policy workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Cancel a running workflow step and block for repair.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - assert.equal(firstTick.workflowStep.metadata.templateStepId, "product-discovery"); - assert.ok(firstTick.runId); - await waitForCondition(() => runner.runs.length === 1, "workflow runner start before run cancellation"); - - const cancelled = await postJson(`${daemon.url}/runs/${encodeURIComponent(firstTick.runId)}/cancel?cwd=${encodeURIComponent(cwd)}`, { - reason: "Cancellation policy regression.", - }); - assert.equal(cancelled.cancelled, true); - const reconciled = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(reconciled.status, "blocked"); - assert.match(reconciled.blocker, /cancelled/i); - const blockedStep = await waitForWorkflowStepStatus(daemon.url, cwd, compiled.workflow.id, firstTick.workflowStep.id, "blocked"); - assert.equal(blockedStep.metadata.policy.onRunCancelled, "block_for_user"); - const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflow.workflow.status, "blocked"); - } finally { - runner.resolve("late completion after cancellation"); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow approval denial follows onApprovalDenied policy and blocks", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-approval-denial-policy-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Approval denial policy workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Deny a pre-step approval and block for user decision.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - await waitForCondition(() => runner.runs.length === 4, "planning and review steps before approval denial"); - const implementationStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "waiting_for_approval"); - const pending = await getJson(`${daemon.url}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(implementationStep.id)}`); - assert.equal(pending.approvals.length, 1); - - await postJson(`${daemon.url}/approvals/${encodeURIComponent(pending.approvals[0].id)}/deny?cwd=${encodeURIComponent(cwd)}`, { - reason: "Implementation plan needs revision.", - }); - const blockedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "implementation", "blocked"); - assert.equal(blockedStep.metadata.policy.onApprovalDenied, "block_for_user"); - const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflow.workflow.status, "blocked"); - assert.equal(workflow.workflow.blocker, undefined); - const deniedTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(deniedTick.status, "blocked"); - assert.equal(deniedTick.workflowStep.id, implementationStep.id); - assert.match(deniedTick.blocker, /revision/); - assert.equal(runner.runs.length, 4); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow skipped optional step allows downstream steps to start", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-skip-optional-")); - const runner = new SelectiveFailingRunner(["Product Review"]); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Skip optional workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Skip optional product review and continue downstream.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const store = stores.get(cwd); - assert.ok(store); - const productReview = compiled.workflowSteps.find((step) => step.metadata?.templateStepId === "product-review"); - assert.ok(productReview); - store.updateChannelWorkflowStep( - productReview.id, - { - metadata: { - ...productReview.metadata, - policy: { - ...productReview.metadata.policy, - onRunFailed: "skip_if_optional", - }, - }, - }, - new Date().toISOString(), - ); - - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - const skippedStep = await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "product-review", "skipped"); - assert.equal(skippedStep.metadata.policy.onRunFailed, "skip_if_optional"); - const downstreamTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(downstreamTick.status, "started"); - assert.equal(downstreamTick.workflowStep.metadata.templateStepId, "design-review"); - await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "design-review", "succeeded"); - await waitForWorkflowTemplateStepStatus(daemon.url, cwd, compiled.workflow.id, "engineering-review", "succeeded"); - assert.equal(runner.runs.some((run) => run.task.title === "Design Review"), true); - assert.equal(runner.runs.some((run) => run.task.title === "Engineering Review"), true); - const workflow = await getJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflow.workflow.status, "waiting_for_approval"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow orchestrator starts independent parallel branches within channel budget", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-parallel-")); - const runner = new ParallelDelayedRunner(); - let cleanupWorkflowId; - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Parallel workflow", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Parallel branches", - objective: "Run independent read-only branch steps.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - const firstStep = createParallelWorkflowStep(store, workflow.id, "Branch A", { - branchGroupId: "branch-a", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - const secondStep = createParallelWorkflowStep(store, workflow.id, "Branch B", { - branchGroupId: "branch-b", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - const thirdStep = createParallelWorkflowStep(store, workflow.id, "Branch C", { - branchGroupId: "branch-c", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - cleanupWorkflowId = workflow.id; - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.startedSteps.length, 2); - assert.equal(tick.schedulingDecision.effectiveMode, "parallel"); - assert.equal(tick.schedulingDecision.parallelLimit, 2); - assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "read_only_parallel_allowed"]); - assert.deepEqual(tick.startedSteps.map((item) => item.workflowStep.id).sort(), [firstStep.id, secondStep.id].sort()); - assert.deepEqual(tick.schedulingDecision.queuedStepIds, [thirdStep.id]); - await runner.waitUntilStarted(2); - const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual(steps.workflowSteps.map((step) => step.status).sort(), ["queued", "running", "running"]); - } finally { - try { - runner.resolveAll("parallel cleanup"); - if (cleanupWorkflowId) { - await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "parallel cleanup"); - } - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("custom workflow template compiles deterministic steps and dependencies", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-")); - const daemon = await startDaemonForTest(); - try { - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Custom template", - status: "active", - boundPath: cwd, - allowedTools: ["read", "grep", "find", "ls"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - objective: "Compile a custom deterministic workflow.", - providerPresetId: "provider_static", - runnerId: "pi", - customTemplate: customTemplate({ - id: "custom-safe-template", - name: "Custom Safe Template", - maxParallelSteps: 2, - steps: [ - { - id: "research", - name: "Research", - prompt: "Research the problem.", - teammateTemplateId: "architect", - skillId: "plan-eng-review", - branchGroupId: "planning", - }, - { - id: "qa", - name: "QA", - prompt: "Verify the result.", - teammateTemplateId: "designer", - skillId: "plan-design-review", - dependsOn: ["research"], - joinGroupId: "review", - dependencyMode: "all_succeeded", - }, - ], - }), - }); - - assert.equal(compiled.workflow.status, "ready"); - assert.equal(compiled.workflow.title, "Custom Safe Template"); - assert.equal(compiled.workflowSteps.length, 2); - assert.equal(compiled.workflowSteps[0].metadata.workflowTemplateId, "custom-safe-template"); - assert.equal(compiled.workflowSteps[0].metadata.templateVersion, "custom:1"); - assert.equal(compiled.workflowSteps[0].metadata.autoStart, false); - assert.equal(compiled.workflowSteps[0].metadata.maxParallelSiblings, 2); - assert.equal(compiled.workflowSteps[0].toParticipantId, "teammate_builtin_architect"); - assert.equal(compiled.workflowSteps[1].metadata.dependsOnStepIds[0], compiled.workflowSteps[0].id); - assert.equal(compiled.workflowSteps[1].metadata.joinGroupId, "review"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow councilSlotKey resolves through attached AI council specialists", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-council-slot-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Council slot workflow"); - const developmentCouncilId = "council_builtin_development"; - await putJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/council-policy?cwd=${encodeURIComponent(cwd)}`, { - attachedCouncilIds: [developmentCouncilId], - preferredCouncilId: developmentCouncilId, - }); - - const customCompiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - objective: "Compile a council-backed custom template.", - customTemplate: customTemplate({ - id: "council-slot-template", - name: "Council Slot Template", - steps: [ - { - id: "implementation", - name: "Implementation", - prompt: "Implement the slice.", - councilSlotKey: "engineer", - skillId: "implement", - }, - ], - }), - }); - - const customStep = customCompiled.workflowSteps[0]; - assert.equal(customStep.toParticipantId, "teammate_builtin_engineer"); - assert.equal(customStep.metadata.councilSlotKey, "engineer"); - assert.equal(customStep.metadata.proposalStepSources[0].kind, "council"); - assert.equal(customStep.metadata.proposalStepSources[0].councilId, developmentCouncilId); - assert.equal(customStep.metadata.proposalStepSources[0].slotKey, "engineer"); - - const graphCompiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - objective: "Compile a council-backed workflow graph.", - workflowGraph: { - schemaVersion: 1, - id: "council_slot_graph", - name: "Council Slot Graph", - entryNodeId: "review", - nodes: [ - { - id: "review", - kind: "step", - name: "Review", - prompt: "Review the plan.", - councilSlotKey: "architect", - skillId: "plan-eng-review", - }, - ], - edges: [], - termination: { - success: "all_required_succeeded", - onFailedRequiredNode: "fail_workflow", - onApprovalDenied: "block_for_user", - }, - maxParallelSteps: 1, - }, - }); - - const graphStep = graphCompiled.workflowSteps[0]; - assert.equal(graphStep.toParticipantId, "teammate_builtin_architect"); - assert.equal(graphStep.metadata.councilSlotKey, "architect"); - assert.equal(graphStep.metadata.proposalStepSources[0].kind, "council"); - assert.equal(graphStep.metadata.proposalStepSources[0].councilId, developmentCouncilId); - assert.equal(graphStep.metadata.proposalStepSources[0].slotKey, "architect"); - - const missingSlot = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - objective: "Reject missing council slot.", - customTemplate: customTemplate({ - id: "missing-council-slot-template", - name: "Missing Council Slot Template", - steps: [ - { - id: "missing", - name: "Missing", - prompt: "Use a missing slot.", - councilSlotKey: "missing-slot", - }, - ], - }), - }), - }); - assert.equal(missingSlot.status, 400); - assert.match((await missingSlot.json()).message, /No effective AI council member found/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("custom workflow template rejects cycles and unknown runners at compile time", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-invalid-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createChannel(daemon.url, cwd); - const cyclic = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - objective: "Reject cycles.", - customTemplate: customTemplate({ - id: "cycle-template", - name: "Cycle Template", - steps: [ - { id: "a", name: "A", prompt: "A", teammateTemplateId: "architect", dependsOn: ["b"] }, - { id: "b", name: "B", prompt: "B", teammateTemplateId: "architect", dependsOn: ["a"] }, - ], - }), - }), - }); - assert.equal(cyclic.status, 400); - assert.equal((await cyclic.json()).error, "bad_request"); - - const unknownRunner = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - objective: "Reject unknown runner.", - customTemplate: customTemplate({ - id: "unknown-runner-template", - name: "Unknown Runner Template", - steps: [ - { id: "a", name: "A", prompt: "A", teammateTemplateId: "architect", requestedRunnerId: "missing-runner" }, - ], - }), - }), - }); - assert.equal(unknownRunner.status, 400); - assert.match((await unknownRunner.json()).message, /not registered|missing-runner/); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("custom workflow template unsafe autostart requires explicit confirmation", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-autostart-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Unsafe custom template"); - const body = { - objective: "Unsafe autostart.", - autoStart: true, - customTemplate: customTemplate({ - id: "unsafe-template", - name: "Unsafe Template", - steps: [ - { - id: "implement", - name: "Implement", - prompt: "Implement changes.", - teammateTemplateId: "engineer", - skillId: "implement", - }, - ], - }), - }; - - const rejected = await fetch(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - assert.equal(rejected.status, 400); - assert.match((await rejected.json()).message, /confirmUnsafeAutoStart/); - - const accepted = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - ...body, - confirmUnsafeAutoStart: true, - }); - assert.equal(accepted.workflowSteps[0].metadata.autoStart, true); - assert.equal(accepted.workflowSteps[0].metadata.skillId, "implement"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("custom workflow template parallel branches start within compiled budget", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-custom-template-parallel-")); - const runner = new ParallelDelayedRunner(); - let cleanupWorkflowId; - const daemon = await startDaemonForTest(runner); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Custom parallel template", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - allowedTools: ["read", "grep", "find", "ls"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - objective: "Run custom parallel branches.", - providerPresetId: provider.id, - autoStart: true, - customTemplate: customTemplate({ - id: "custom-parallel", - name: "Custom Parallel", - maxParallelSteps: 2, - steps: [ - { - id: "branch-a", - name: "Branch A", - prompt: "Read branch A.", - teammateTemplateId: "architect", - skillId: "plan-eng-review", - branchGroupId: "a", - }, - { - id: "branch-b", - name: "Branch B", - prompt: "Read branch B.", - teammateTemplateId: "architect", - skillId: "plan-eng-review", - branchGroupId: "b", - }, - ], - }), - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.startedSteps.length, 2); - cleanupWorkflowId = compiled.workflow.id; - await runner.waitUntilStarted(2); - assert.deepEqual(runner.runs.map((run) => run.task.title).sort(), ["Branch A", "Branch B"]); - } finally { - try { - runner.resolveAll("custom parallel cleanup"); - if (cleanupWorkflowId) { - await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "custom parallel cleanup"); - } - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("workflow orchestrator keeps serial behavior when smart scheduling max runs is one", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-serial-budget-")); - const runner = new ParallelDelayedRunner(); - let cleanupWorkflowId; - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Serial budget workflow"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - name: channel.name, - status: channel.status, - boundPath: channel.boundPath, - defaultProviderPresetId: provider.id, - defaultTeammateId: channel.defaultTeammateId, - allowedTools: ["read"], - runtimePolicy: { ...channel.runtimePolicy, queueOnConflict: false }, - }); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Serial branches", - objective: "Keep default single-start behavior.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - createParallelWorkflowStep(store, workflow.id, "Serial A", { - branchGroupId: "branch-a", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - createParallelWorkflowStep(store, workflow.id, "Serial B", { - branchGroupId: "branch-b", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.startedSteps.length, 1); - assert.equal(tick.schedulingDecision.effectiveMode, "serial"); - assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "max_concurrent_runs_one"]); - cleanupWorkflowId = workflow.id; - await runner.waitUntilStarted(1); - const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - assert.deepEqual(steps.workflowSteps.map((step) => step.status).sort(), ["queued", "running"]); - runner.resolveAll("serial source completed"); - await runner.waitUntilStarted(2); - await waitForCondition(() => store.getChannelWorkflow(workflow.id)?.status === "succeeded", "serial successor to finish without a manual tick"); - } finally { - try { - runner.resolveAll("serial cleanup"); - if (cleanupWorkflowId) { - await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "serial cleanup", { drainQueued: true }); - } - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("workflow smart scheduling keeps side-effectful branches serial", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-write-")); - const runner = new ParallelDelayedRunner(); - let cleanupWorkflowId; - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart write workflow"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - runtimePolicy: { - ...channel.runtimePolicy, - maxConcurrentRuns: 2, - }, - defaultProviderPresetId: provider.id, - }); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Write branches", - objective: "Keep write branches serial.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - createParallelWorkflowStep(store, workflow.id, "Write A", { - branchGroupId: "branch-a", - maxParallelSiblings: 2, - providerPresetId: provider.id, - requiredTools: ["write"], - }); - createParallelWorkflowStep(store, workflow.id, "Write B", { - branchGroupId: "branch-b", - maxParallelSiblings: 2, - providerPresetId: provider.id, - requiredTools: ["write"], - }); - cleanupWorkflowId = workflow.id; - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.startedSteps.length, 1); - assert.equal(tick.schedulingDecision.effectiveMode, "serial"); - assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "side_effectful_step_serial"]); - await runner.waitUntilStarted(1); - } finally { - try { - runner.resolveAll("write cleanup"); - if (cleanupWorkflowId) { - await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "write cleanup", { drainQueued: true }); - } - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("workflow smart scheduling keeps unknown-tool branches serial", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-unknown-")); - const runner = new ParallelDelayedRunner(); - let cleanupWorkflowId; - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart unknown workflow"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - runtimePolicy: { - ...channel.runtimePolicy, - maxConcurrentRuns: 2, - }, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - }); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Unknown branches", - objective: "Keep unknown tools serial.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - createParallelWorkflowStep(store, workflow.id, "Unknown A", { - branchGroupId: "branch-a", - maxParallelSiblings: 2, - providerPresetId: provider.id, - omitRequiredTools: true, - }); - createParallelWorkflowStep(store, workflow.id, "Unknown B", { - branchGroupId: "branch-b", - maxParallelSiblings: 2, - providerPresetId: provider.id, - omitRequiredTools: true, - }); - cleanupWorkflowId = workflow.id; - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.startedSteps.length, 1); - assert.equal(tick.schedulingDecision.effectiveMode, "serial"); - assert.equal(tick.schedulingDecision.safetyEvidence, "unknown"); - assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "unknown_tools_serial"]); - await runner.waitUntilStarted(1); - } finally { - try { - runner.resolveAll("unknown cleanup"); - if (cleanupWorkflowId) { - await settleParallelDelayedWorkflow(daemon.url, cwd, cleanupWorkflowId, runner, "unknown cleanup", { drainQueued: true }); - } - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } - } -}); - -test("workflow smart scheduling does not start sibling branches while approval is pending", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-smart-approval-")); - const runner = new ParallelDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Smart approval workflow"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - runtimePolicy: { - ...channel.runtimePolicy, - maxConcurrentRuns: 2, - }, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - }); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Approval branches", - objective: "Wait for approval before siblings.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - createParallelWorkflowStep(store, workflow.id, "Approval A", { - branchGroupId: "branch-a", - maxParallelSiblings: 2, - providerPresetId: provider.id, - approvalGate: { mode: "standard", risk: "medium" }, - }); - createParallelWorkflowStep(store, workflow.id, "Approval B", { - branchGroupId: "branch-b", - maxParallelSiblings: 2, - providerPresetId: provider.id, - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "waiting_for_approval"); - assert.equal(tick.waitingApprovals.length, 1); - assert.equal(tick.schedulingDecision.effectiveMode, "serial"); - assert.deepEqual(tick.schedulingDecision.reasonCodes, ["smart_scheduling", "approval_pending"]); - assert.equal(runner.runs.length, 0); - const steps = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/steps?cwd=${encodeURIComponent(cwd)}`); - assert.equal(steps.workflowSteps.filter((step) => step.status === "waiting_for_approval").length, 1); - assert.equal(steps.workflowSteps.filter((step) => step.status === "queued").length, 1); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow dependencyMode any_succeeded can start a fan-in after mixed terminal branches", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-any-succeeded-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Any succeeded workflow", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const now = new Date().toISOString(); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Fan-in", - objective: "Start when one dependency succeeded.", - status: "ready", - createdBy: "local-user", - }, now); - const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded branch", { - branchGroupId: "branch-a", - providerPresetId: provider.id, - status: "succeeded", - }); - const failed = createParallelWorkflowStep(store, workflow.id, "Failed branch", { - branchGroupId: "branch-b", - providerPresetId: provider.id, - status: "failed", - }); - const fanIn = createParallelWorkflowStep(store, workflow.id, "Fan-in", { - branchGroupId: "join", - joinGroupId: "join-main", - providerPresetId: provider.id, - dependsOnStepIds: [succeeded.id, failed.id], - dependencyMode: "any_succeeded", - maxParallelSiblings: 2, - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.workflowStep.id, fanIn.id); - assert.equal(tick.startedSteps.length, 1); - assert.equal(runner.runs.length, 1); - assert.equal(runner.runs[0].task.title, "Fan-in"); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("fan-in requireAllBranchesTerminal is a scheduling barrier even with any_succeeded", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-barrier-")); - const runner = new ParallelDelayedRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Fan-in barrier workflow"); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - }); - const store = stores.get(cwd); - assert.ok(store); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Fan-in barrier", - objective: "Do not start synthesis before every branch is terminal.", - status: "ready", - createdBy: "local-user", - }, new Date().toISOString()); - const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded branch", { - branchGroupId: "branch-a", - providerPresetId: provider.id, - status: "succeeded", - }); - const pending = createParallelWorkflowStep(store, workflow.id, "Pending branch", { - branchGroupId: "branch-b", - providerPresetId: provider.id, - status: "queued", - autoStart: false, - }); - const fanIn = createParallelWorkflowStep(store, workflow.id, "Barrier fan-in", { - branchGroupId: "join", - joinGroupId: "join-barrier", - providerPresetId: provider.id, - position: 3, - dependsOnStepIds: [succeeded.id, pending.id], - dependencyMode: "any_succeeded", - maxParallelSiblings: 2, - fanInPolicy: { - mode: "memory_candidates", - requireAllBranchesTerminal: true, - includeFailedBranches: true, - createSummaryArtifact: false, - createMemoryCandidates: false, - memoryCandidateScope: "channel", - maxInputArtifacts: 8, - }, - }); - const independent = createParallelWorkflowStep(store, workflow.id, "Independent queued branch", { - branchGroupId: "branch-c", - providerPresetId: provider.id, - position: 4, - maxParallelSiblings: 2, - }); - - const before = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(before.status, "started"); - assert.equal(before.startedSteps.length, 1); - assert.equal(before.workflowStep.id, independent.id); - assert.equal(runner.runs.length, 1); - assert.equal(runner.runs[0].task.title, independent.title); - assert.equal(store.getChannelWorkflowStep(fanIn.id).status, "queued"); - - const cancelled = store.transitionChannelWorkflowStepStatus({ - workflowStepId: pending.id, - expectedStatus: "queued", - event: "cancel", - actor: "system", - reason: "pending branch was cancelled after its predecessor was unavailable", - now: new Date().toISOString(), - }); - assert.equal(cancelled.ok, true); - - runner.resolveAll("independent branch complete"); - await waitForCondition( - () => store.getChannelWorkflowStep(independent.id)?.status === "succeeded", - "independent branch to finish before fan-in", - ); - await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - await waitForCondition( - () => runner.runs.filter((run) => run.task.title === fanIn.title).length === 1, - "fan-in to start after every dependency becomes terminal", - ); - assert.equal(runner.runs.filter((run) => run.task.title === fanIn.title).length, 1); - } finally { - runner.resolveAll("cleanup"); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow fan-in creates summary artifact, memory candidate, selector decision, and evidence drilldown", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-fanin-evidence-")); - const runner = new RecordingRunner(); - const stores = new Map(); - const daemon = await startDaemonForTest(runner, { - storeFactory: (storeCwd) => { - const store = createSqliteRunStore(storeCwd); - stores.set(storeCwd, store); - return store; - }, - }); - try { - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${daemon.url}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Fan-in evidence workflow", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 2, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; - const store = stores.get(cwd); - assert.ok(store); - const now = new Date().toISOString(); - const workflow = store.createChannelWorkflow({ - channelId: channel.id, - title: "Fan-in evidence", - objective: "Create auditable fan-in evidence.", - status: "ready", - createdBy: "local-user", - }, now); - const succeeded = createParallelWorkflowStep(store, workflow.id, "Succeeded Evidence Branch", { - branchGroupId: "branch-a", - providerPresetId: provider.id, - status: "succeeded", - }); - const failed = createParallelWorkflowStep(store, workflow.id, "Failed Evidence Branch", { - branchGroupId: "branch-b", - providerPresetId: provider.id, - status: "failed", - }); - const fanIn = createParallelWorkflowStep(store, workflow.id, "Fan-in Evidence", { - branchGroupId: "join", - joinGroupId: "join-evidence", - providerPresetId: provider.id, - dependsOnStepIds: [succeeded.id, failed.id], - dependencyMode: "any_succeeded", - maxParallelSiblings: 2, - fanInPolicy: { - mode: "memory_candidates", - requireAllBranchesTerminal: true, - includeFailedBranches: true, - createSummaryArtifact: true, - createMemoryCandidates: true, - memoryCandidateScope: "channel", - maxInputArtifacts: 8, - }, - }); - - const tick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(tick.status, "started"); - assert.equal(tick.workflowStep.id, fanIn.id); - const completedFanIn = await waitForWorkflowStepStatus(daemon.url, cwd, workflow.id, fanIn.id, "succeeded"); - assert.ok(completedFanIn.metadata.selectorDecisionIds.length > 0); - assert.ok(completedFanIn.metadata.outputArtifactId); - assert.ok(completedFanIn.metadata.fanInSummaryArtifactId); - assert.equal(completedFanIn.metadata.memoryCandidateIds.length, 1); - - const outputPacket = await getArtifactJson(daemon.url, cwd, completedFanIn.metadata.outputArtifactId); - assert.equal(outputPacket.sourceRefs.some((ref) => ref.type === "workflow_selector_decision" && ref.id === completedFanIn.metadata.selectorDecisionIds[0]), true); - - const workflowEvidence = await getJson(`${daemon.url}/workflows/${encodeURIComponent(workflow.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); - assert.equal(workflowEvidence.selectorDecisions.some((decision) => decision.id === completedFanIn.metadata.selectorDecisionIds[0] && decision.status === "accepted"), true); - assert.equal(workflowEvidence.memoryCandidates.some((candidate) => - candidate.memory.id === completedFanIn.metadata.memoryCandidateIds[0] && - candidate.memory.reviewState === "candidate" && - candidate.memory.verifyBeforeUse === true && - candidate.sourceWorkflowStepIds.includes(completedFanIn.id) - ), true); - - const stepEvidence = await getJson(`${daemon.url}/workflow-steps/${encodeURIComponent(completedFanIn.id)}/evidence?cwd=${encodeURIComponent(cwd)}`); - assert.equal(stepEvidence.workflowStep.step.id, completedFanIn.id); - assert.equal(stepEvidence.selectorDecisions.length, 1); - assert.equal(stepEvidence.memoryCandidates.length, 1); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow cancel aborts active run, queued steps, and pending workflow approvals", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-cancel-")); - const runner = new DelayedRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Cancel workflow"); - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Cancel the workflow.", - providerPresetId: provider.id, - createdBy: "local-user", - }); - const firstTick = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(firstTick.status, "started"); - await waitForCondition(() => runner.runs.length === 1, "workflow runner start before cancel"); - - const cancelled = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/cancel?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(cancelled.workflow.status, "cancelled"); - assert.equal(cancelled.cancelledRunIds.length, 1); - assert.deepEqual(cancelled.workflowSteps.map((step) => step.status), Array(8).fill("cancelled")); - - const run = await getJson(`${daemon.url}/runs/${encodeURIComponent(cancelled.cancelledRunIds[0])}?cwd=${encodeURIComponent(cwd)}`); - assert.equal(run.status, "cancelled"); - } finally { - runner.resolve("cleanup"); - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("workflow step retry starts a blocked step after configuration is repaired", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-workflow-retry-")); - const runner = new RecordingRunner(); - const daemon = await startDaemonForTest(runner); - try { - const channel = await createWorkflowTestChannel(daemon.url, cwd, "Retry workflow"); - simulateLegacyChannelWithoutModel(cwd, channel.id); - const compiled = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/workflows?cwd=${encodeURIComponent(cwd)}`, { - templateId: "autoplan-lite", - objective: "Retry a blocked workflow step.", - createdBy: "local-user", - }); - const blocked = await postJson(`${daemon.url}/workflows/${encodeURIComponent(compiled.workflow.id)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(blocked.status, "blocked"); - assert.equal(blocked.workflowStep.metadata.templateStepId, "product-discovery"); - assert.match(blocked.blocker, /provider preset/i); - - const provider = (await createStaticChatProvider(daemon.url, cwd, { - capabilities: ["chat", "reasoning", "tool_call"], - })).provider; - await postJson(`${daemon.url}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - await patchJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}?cwd=${encodeURIComponent(cwd)}`, { - defaultProviderPresetId: provider.id, - }); - - const retried = await postJson(`${daemon.url}/workflow-steps/${encodeURIComponent(blocked.workflowStep.id)}/retry?cwd=${encodeURIComponent(cwd)}`, {}); - assert.equal(retried.status, "started"); - assert.equal(retried.workflowStep.metadata.templateStepId, "product-discovery"); - await waitForCondition(() => runner.runs.length >= 1, "workflow retry runner start"); - assert.equal(runner.runs[0].task.title, "Product Discovery"); - const roster = await getJson(`${daemon.url}/teammates?cwd=${encodeURIComponent(cwd)}&includeHidden=true&includeDisabled=true`); - const assignedTeammate = roster.teammates.find((teammate) => teammate.id === retried.workflowStep.toParticipantId); - assert.ok(assignedTeammate); - assert.equal(runner.runs[0].systemPrompt, assignedTeammate.systemPrompt); - assert.deepEqual( - runner.runs[0].appendSystemPrompt.slice(0, assignedTeammate.appendSystemPrompt.length), - assignedTeammate.appendSystemPrompt, - ); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); - -test("channel link preview blocks localhost targets instead of fetching them", async () => { - const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-channel-links-")); - const daemon = await startDaemonForTest(); - try { - const channel = await createChannel(daemon.url, cwd); - const preview = await postJson(`${daemon.url}/channels/${encodeURIComponent(channel.id)}/link-previews?cwd=${encodeURIComponent(cwd)}`, { - url: "http://localhost:12345/private", - }); - assert.equal(preview.linkPreview.status, "blocked"); - assert.equal(preview.linkPreview.errorMessage, "Public HTTP blocks local or private network targets."); - } finally { - await daemon.close(); - await rm(cwd, { recursive: true, force: true }); - } -}); +import { createCompositeSecretResolver, createSqliteRunStore, resolveStoragePaths } from "../../core/dist/index.js"; +import { assertChannelWorkspaceReady } from "../dist/features/channels/workspace.js"; +import { ensureDirectWorkspaceReady } from "../dist/features/teammates/direct-workspace.js"; +import { createInitialWorkspaceBinding, createManagedGitWorktree, resolveGitRepoRoot } from "../dist/features/workspaces/managed-worktrees.js"; +import { prepareChannelCreateBody } from "./conversation-model-test-fixture.mjs"; +import { ensureEmptyGitRepo, ensureGitRepo } from "./git-test-helpers.mjs"; +import { startMockOpenAICompatibleServer } from "./support/mock-openai-compatible-server.mjs"; +import { + ASYNC_SETTLE_TIMEOUT_MS, + readDocxFixtureBytes, + RecordingRunner, + DirectToolRunner, + DirectApprovalRunner, + AbortableDelayedRunner, + startDaemonForTest, + createChannel, + createStaticChatProvider, + postTaskRunAndReadReadyRunId, + parseAcceptedPrivateSseEvents, + privateReplyTextEvents, + parseSseEvents, + getJson, + waitForChannelMessageStatus, + waitForCondition, + postJson, + putJson, + patchJson, +} from "./support/channel-workspace-fixture.mjs"; test("AI teammate private workspace endpoints persist roster, setup, private messages, and activity", async () => { const cwd = await mkdtemp(resolve(tmpdir(), "ling-stack-ai-teammate-api-")); @@ -15314,1134 +3695,3 @@ test("private attachments avatar and hard delete stay scoped to teammate private await rm(cwd, { recursive: true, force: true }); } }); - -class SummaryAwareRunner { - constructor() { - this.runs = []; - this.summaryRuns = []; - this.replyRuns = []; - this.failSummary = false; - } - - async runTask(options) { - this.runs.push(options); - const summary = /conversation summary coordinator/i.test(options.systemPrompt ?? ""); - if (summary && this.failSummary) { - this.summaryRuns.push(options); - throw new Error("synthetic summary provider failure"); - } - const text = summary - ? JSON.stringify({ - objective: "Preserve the durable historical objective.", - decisions: ["Use proactive summaries before provider rejection."], - constraints: ["Keep the recent raw tail."], - openQuestions: [], - }) - : "Reply after proactive summary."; - (summary ? this.summaryRuns : this.replyRuns).push(options); - options.onEvent?.({ type: "assistant_text_delta", text }); - return { - taskId: options.task.id, - messages: [{ role: "assistant", content: text }], - events: [], - }; - } -} - -class RecordingRunner extends ToolFreePiRunner { - constructor() { - super(); - this.runs = []; - } - - async runTask(options) { - this.runs.push(options); - return super.runTask(options, () => "Designer reply"); - } -} - -class WorkflowEvidenceRunner { - constructor() { - this.runs = []; - } - - async runTask(options) { - this.runs.push(options); - const toolCallId = `workflow_read_${this.runs.length}`; - options.onEvent?.({ - type: "tool_start", - toolCallId, - toolName: "read", - args: { path: "package.json" }, - }); - options.onEvent?.({ - type: "tool_end", - toolCallId, - toolName: "read", - result: { path: "package.json", content: [{ type: "text", text: "fixture" }] }, - isError: false, - }); - options.onEvent?.({ type: "assistant_text_delta", text: "Designer reply" }); - return { - taskId: options.task.id, - messages: ["Designer reply"], - events: [], - }; - } -} - -class DelayedCommandEvidenceRunner { - constructor(command) { - this.command = command; - this.executionRuns = []; - this.selectorRuns = []; - this.reportRuns = []; - this.pendingExecution = undefined; - } - - async runTask(options) { - if (/terminal report synthesizer/i.test(options.systemPrompt ?? "")) { - this.reportRuns.push(options); - options.onEvent?.({ type: "assistant_text_delta", text: "{}" }); - return { taskId: options.task.id, messages: ["{}"], events: [] }; - } - if (/channel intent selector/i.test(options.systemPrompt ?? "")) { - this.selectorRuns.push(options); - const text = JSON.stringify({ - action: "execute", - reason: "The user explicitly requested one bounded command execution.", - execution: this.selectorExecution ?? { schemaVersion: 2, - operations: [commandOperationCandidate({ sourceQuote: this.command, command: this.command })] }, - }); - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } - this.executionRuns.push(options); - await new Promise((resolveExecution) => { - this.pendingExecution = resolveExecution; - }); - const toolCallId = "single_participant_node"; - options.onEvent?.({ - type: "tool_start", - toolCallId, - toolName: "bash", - args: { command: this.command }, - }); - options.onEvent?.({ - type: "tool_end", - toolCallId, - toolName: "bash", - result: { - content: [{ type: "text", text: "proof:7319" }], - details: { - unifiedExec: { - status: "succeeded", - cwd: options.cwd, - command: { kind: "shell", command: this.command, shell: "bash" }, - startedAt: "2026-11-02T00:00:00.000Z", - endedAt: "2026-11-02T00:00:00.000Z", - durationMs: 0, - exitCode: 0, - timedOut: false, - cancelled: false, - stdout: { byteCount: 10, preview: "proof:7319", truncated: false }, - stderr: { byteCount: 0, preview: "", truncated: false }, - artifacts: [], - }, - }, - }, - isError: false, - }); - const text = "Verified proof:7319."; - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } - - releaseExecution() { - this.pendingExecution?.(); - this.pendingExecution = undefined; - } -} - -class MismatchedExactCommandRunner { - constructor() { - this.decisions = []; - this.executionRuns = []; - } - - async runTask(options) { - if (/terminal report synthesizer/i.test(options.systemPrompt ?? "")) { - options.onEvent?.({ type: "assistant_text_delta", text: "{}" }); - return { taskId: options.task.id, messages: ["{}"], events: [] }; - } - if (/channel intent selector/i.test(options.systemPrompt ?? "")) { - const text = JSON.stringify({ - action: "execute", - reason: "The user explicitly requested one bounded command execution.", - execution: { schemaVersion: 2, operations: [commandOperationCandidate({ - sourceQuote: "@QA 执行 `node app.js` 验证,并且必须提供真实 evidence。", command: "node app.js", - owner: { participantId: "teammate_builtin_qa", sourceQuote: "QA" } })] }, - }); - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } - - this.executionRuns.push(options); - const toolCallId = "mismatched_exact_command"; - const input = { command: "node -e \"console.log('semantic substitute')\"" }; - options.onEvent?.({ type: "tool_start", toolCallId, toolName: "bash", args: input }); - const decision = await options.onApprovalRequired?.({ - toolCallId, - toolName: "bash", - input, - task: options.task, - cwd: options.cwd, - }); - this.decisions.push(decision); - options.onEvent?.({ - type: "tool_end", - toolCallId, - toolName: "bash", - result: { approved: decision?.approved, reason: decision?.reason }, - isError: !decision?.approved, - }); - const text = decision?.approved ? "Unexpectedly executed substitute command." : "Blocked: exact command binding did not match."; - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } -} - -class DelayedFileCommandRunner { - constructor() { - this.executionRuns = []; - this.selectorRuns = []; - this.pendingResolvers = []; - } - - async runTask(options) { - if (/channel intent selector/i.test(options.systemPrompt ?? "")) { - this.selectorRuns.push(options); - const text = JSON.stringify({ - action: "execute", - reason: "The user explicitly requested a file write followed by a Node verification.", - execution: this.selectorExecution, - }); - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } - this.executionRuns.push(options); - await new Promise((resolveExecution) => this.pendingResolvers.push(resolveExecution)); - const text = "Execution released for cleanup."; - options.onEvent?.({ type: "assistant_text_delta", text }); - return { taskId: options.task.id, messages: [text], events: [] }; - } - - releaseAll() { - this.pendingResolvers.splice(0).forEach((resolveExecution) => resolveExecution()); - } -} - -class DirectToolRunner { - constructor() { - this.runs = []; - } - - async runTask(options) { - this.runs.push(options); - options.onEvent?.({ - type: "tool_start", - toolCallId: "direct_read_package", - toolName: "read", - args: { path: "package.json" }, - }); - options.onEvent?.({ - type: "tool_end", - toolCallId: "direct_read_package", - toolName: "read", - result: { path: "package.json", content: [{ type: "text", text: "{\"name\":\"ling-stack\"}" }] }, - isError: false, - }); - options.onEvent?.({ type: "assistant_text_delta", text: "Read package.json successfully." }); - return { - taskId: options.task.id, - messages: [{ role: "assistant", content: "Read package.json successfully." }], - events: [], - }; - } -} - -class DirectApprovalRunner { - constructor() { - this.runs = []; - this.decisions = []; - } - - async runTask(options) { - this.runs.push(options); - options.onEvent?.({ - type: "tool_start", - toolCallId: "direct_private_bash", - toolName: "bash", - args: { command: "printf private-approved" }, - }); - const decision = await options.onApprovalRequired?.({ - toolCallId: "direct_private_bash", - toolName: "bash", - input: { command: "printf private-approved" }, - task: options.task, - cwd: options.cwd, - defaultApproval: "ask", - }); - assert.ok(decision); - this.decisions.push(decision); - options.onEvent?.({ - type: "tool_end", - toolCallId: "direct_private_bash", - toolName: "bash", - result: { approved: decision.approved }, - isError: !decision.approved, - }); - if (!decision.approved) { - throw new Error(decision.reason ?? "Private shell approval denied."); - } - const text = "Private shell check completed after approval."; - options.onEvent?.({ type: "assistant_text_delta", text }); - return { - taskId: options.task.id, - messages: [{ role: "assistant", content: text }], - events: [], - }; - } -} - -class PromptEchoRunner extends ToolFreePiRunner { - constructor() { - super(); - this.runs = []; - } - - async runTask(options) { - return super.runTask(options, () => { - this.runs.push(options); - return options.task.prompt; - }); - } -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -class ScriptedRunner extends ToolFreePiRunner { - constructor(responses) { - super(); - this.responses = responses.slice(); - this.runs = []; - } - - async runTask(options) { - return super.runTask(options, () => { - this.runs.push(options); - const queuedResponse = this.responses.shift(); - const response = typeof queuedResponse === "function" ? queuedResponse(options) : queuedResponse; - if (response instanceof Error) throw response; - return response ?? ""; - }); - } -} - -class SelectiveFailingRunner extends ToolFreePiRunner { - constructor(failingTitles) { - super(); - this.failingTitles = new Set(failingTitles); - this.runs = []; - } - - async runTask(options) { - return super.runTask(options, () => { - this.runs.push(options); - if (this.failingTitles.has(options.task.title)) throw new Error(`${options.task.title} failed by test runner`); - return `${options.task.title} complete`; - }); - } -} - -class DelayedRunner extends ToolFreePiRunner { - constructor() { - super(); - this.runs = []; - this.completed = false; - this.pending = undefined; - } - - async runTask(options) { - return super.runTask(options, async () => { - this.runs.push(options); - const text = await new Promise((resolve) => { - this.pending = resolve; - }); - this.completed = true; - return text; - }); - } - - resolve(text) { - this.pending?.(text); - this.pending = undefined; - } -} - -class AbortableDelayedRunner extends ToolFreePiRunner { - constructor() { - super(); - this.runs = []; - this.abortCount = 0; - this.pending = undefined; - } - - async runTask(options) { - return super.runTask(options, () => { - this.runs.push(options); - return new Promise((resolve, reject) => { - this.pending = { resolve, reject }; - options.signal?.addEventListener("abort", () => { - this.abortCount += 1; - reject(new Error("aborted by test")); - }, { once: true }); - }); - }); - } - - resolve(text) { - this.pending?.resolve(text); - this.pending = undefined; - } -} - -function readCurrentChannelUserMessage(prompt) { - const matches = [...prompt.matchAll(/User message:\nTurn envelope:\nText:\n([^\n]+)/g)]; - return matches.at(-1)?.[1]; -} - -class ParallelDelayedRunner extends ToolFreePiRunner { - constructor() { - super(); - this.runs = []; - this.pendingResolvers = []; - this.startedResolvers = []; - this.autoResolveText = undefined; - } - - async runTask(options) { - this.runs.push(options); - this.startedResolvers.splice(0).forEach((resolveStarted) => resolveStarted()); - return super.runTask(options, async () => { - return this.autoResolveText ?? new Promise((resolve) => this.pendingResolvers.push(resolve)); - }); - } - - async waitUntilStarted(count) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - while (Date.now() < deadline) { - if (this.runs.length >= count) { - return; - } - await new Promise((resolve) => { - this.startedResolvers.push(resolve); - setTimeout(resolve, 20); - }); - } - assert.fail(`Timed out waiting for ${count} parallel runs. Started: ${this.runs.length}`); - } - - resolveAll(text) { - this.autoResolveText = text; - this.pendingResolvers.splice(0).forEach((resolvePending) => resolvePending(text)); - } -} - -function testDaemonConfig(cwd) { - return { - host: "127.0.0.1", - port: 0, - maxBodyBytes: 1024 * 1024, - defaultWorkspaceCwd: cwd, - runners: {}, - runtimeTools: {}, - channels: { autoRoute: false, intentSelector: false, terminalReports: false }, - }; -} - -function unusedRunnerRegistry() { - return { - get() { - return undefined; - }, - list() { - return []; - }, - resolve() { - throw new Error("runner registry should not be used by deterministic route test"); - }, - }; -} - -async function assertInvalidModelPlan(baseUrl, cwd, channelId, sourceItemId, field) { - const decisions = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(sourceItemId)}`); - assert.equal(decisions.decisions.length, 1); - const decision = decisions.decisions[0]; - assert.equal(decision.action, "reply", "failed Host diagnostic only, not an accepted ordinary reply"); - assert.equal(decision.status, "failed"); - assert.equal(decision.errorCode, "channel_operation_plan_invalid"); - assert.equal(decision.createdBy, "model_policy"); - assert.equal(decision.execution, undefined); - const observations = await getJson(`${baseUrl}/control-plane/events?cwd=${encodeURIComponent(cwd)}&domains=runners&limit=1000`); - const rejected = observations.events.filter(event => event.entityType === "channel_intent_selection" - && event.entityId === sourceItemId && event.data.outcome === "binding_rejected"); - assert.deepEqual(rejected.map(event => event.data.fields), [[field]]); - const owned = observations.events.filter(event => event.entityType === "channel_intent_selection" && event.entityId === sourceItemId); - assert.deepEqual(owned.filter(event => event.action === "channel_intent.request_started").map(event => - [event.data.requestOrdinal, event.data.purpose]), [[1, "initial"], [2, "binding_repair"]]); - // ScriptedRunner has one response: its repair deliberately returns empty - // text. Keep that parse failure distinct from a second binding rejection. - assert.deepEqual(owned.filter(event => event.data.outcome === "intent_schema_rejected").map(event => event.data.requestOrdinal), [2]); -} - -async function startDaemonForTest(runner, dependencies = {}, config = {}) { - const { channels, testVerifiedPiRunner, ...restConfig } = config; - const executionRunner = testVerifiedPiRunner && runner - ? new VerifiedExecutionTestRunner(runner) - : runner; - const routedRunner = wrapTestRunnerContextSegmentation(executionRunner); - const daemon = createDaemonServer({ - host: "127.0.0.1", - port: 0, - ...restConfig, - channels: { intentSelector: false, terminalReports: false, ...channels }, - }, { - ...dependencies, - ...(routedRunner ? { runner: routedRunner } : {}), - }); - await daemon.start(); - const address = daemon.server.address(); - assert.ok(address && typeof address === "object"); - return { - url: `http://127.0.0.1:${address.port}`, - server: daemon.server, - close: () => daemon.stop(), - }; -} - -async function publishHostOpenAvailability(baseUrl, bridgeToken) { - const response = await fetch(new URL("/desktop/capabilities/host/availability", baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - "x-ling-desktop-bridge-token": bridgeToken, - }, - body: JSON.stringify({ - protocol: "ling-host-capability-bridge/v1", - desktopInstanceId: "desktop-channel-test", - generation: 1, - capabilities: ["host.open_local_target"], - checkedAt: new Date().toISOString(), - }), - }); - assert.equal(response.status, 200, await response.text()); -} - -function wrapTestRunnerContextSegmentation(runner) { - if (!runner) { - return undefined; - } - runner.segmentationRuns ??= []; - const routed = Object.create(runner); - routed.prepareTaskInput = runner.prepareTaskInput?.bind(runner) ?? prepareFixtureTaskInput; - routed.runTask = async (options) => { - if (!/context segmentation policy proposer/i.test(options.systemPrompt ?? "")) { - return runner.runTask(options); - } - runner.segmentationRuns.push(options); - const configuredResponse = runner.segmentationResponses?.shift(); - const text = typeof configuredResponse === "function" - ? configuredResponse(options) - : configuredResponse ?? JSON.stringify({ action: "uncertain", candidateSegmentIds: [] }); - options.onEvent?.({ type: "assistant_text_delta", text }); - return { - taskId: options.task.id, - messages: [text], - events: [], - }; - }; - return routed; -} - -function createParallelWorkflowStep(store, workflowId, title, metadata = {}) { - return store.createChannelWorkflowStep( - { - workflowId, - position: metadata.position ?? 1, - title, - prompt: `Complete ${title}.`, - toParticipantId: "teammate_builtin_engineer", - toParticipantLabel: "Engineer", - participantAuthority: createWorkflowParticipantAuthorityV1({ - kind: "workflow_definition", - origin: "workflow_graph", - participantId: "teammate_builtin_engineer", - sourceId: workflowId, - }), - status: metadata.status, - metadata: { - templateStepId: title.toLowerCase().replaceAll(" ", "-"), - branchGroupId: metadata.branchGroupId, - joinGroupId: metadata.joinGroupId, - dependsOnStepIds: metadata.dependsOnStepIds, - dependencyMode: metadata.dependencyMode, - fanInPolicy: metadata.fanInPolicy, - autoStart: metadata.autoStart, - maxParallelSiblings: metadata.maxParallelSiblings, - providerPresetId: metadata.providerPresetId, - requiredTools: metadata.omitRequiredTools ? undefined : metadata.requiredTools ?? ["read"], - approvalGate: metadata.approvalGate, - }, - }, - new Date().toISOString(), - ); -} - -function createStoreChannelAndMessage(store, cwd, body) { - const now = "2026-06-11T00:00:00.000Z"; - const channel = store.createChannel({ - name: "Ling Agent Session", - status: "active", - boundPath: cwd, - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - }, now); - const message = appendChannelItemFixture(store, { - channelId: channel.id, - authorType: "local-user", - body, - }, now); - return { channel, message }; -} - -function createActiveChannelReplyRunFixture(store, input) { - const teammateId = input.teammateId ?? "ling_agent"; - const sourceMessage = appendChannelItemFixture(store, { - channelId: input.channelId, - kind: "message", - intent: "discuss", - authorType: "local-user", - body: "Source message for the active channel reply.", - status: "ready", - }, "2026-06-11T00:00:55.000Z"); - const replyMessage = appendChannelItemFixture(store, { - channelId: input.channelId, - replyToItemId: sourceMessage.id, - kind: "ai_reply", - intent: "discuss", - authorType: "ai-teammate", - authorId: teammateId, - body: "", - status: "pending", - }, "2026-06-11T00:00:56.000Z"); - const workUnit = store.createWorkUnit({ - id: `wu_${input.runId}`, - workspaceId: DEFAULT_WORKSPACE_ID, - kind: "channel_ai_reply", - ownerType: "channel_message", - ownerId: sourceMessage.id, - channelId: input.channelId, - teammateId, - idempotencyKey: `channel-reply-fixture:${input.runId}`, - createdByActor: "system", - request: { sourceMessageId: sourceMessage.id }, - linkedMessageId: replyMessage.id, - now: "2026-06-11T00:00:57.000Z", - }); - const run = store.createRun({ - id: input.runId, - cwd: input.cwd, - taskId: input.taskId, - channelId: input.channelId, - threadId: sourceMessage.threadId, - teammateId, - providerName: "test-provider", - providerModel: "test-model", - taskSnapshot: { id: input.taskId, title: "Active work" }, - providerSnapshot: { provider: "test-provider", model: "test-model" }, - requestSnapshot: { - cwd: input.cwd, - sourceMessageId: sourceMessage.id, - linkedMessageId: replyMessage.id, - }, - workUnitId: workUnit.id, - createdAt: "2026-06-11T00:01:00.000Z", - }); - store.linkRunToWorkUnit(run.id, workUnit.id, "2026-06-11T00:01:30.000Z"); - updateChannelItemFixture(store, replyMessage.id, { linkedRunId: run.id }, "2026-06-11T00:01:31.000Z"); - store.markRunStarted(run.id, "2026-06-11T00:02:00.000Z"); - return store.getRun(run.id); -} - -function customTemplate(overrides = {}) { - return { - schemaVersion: 1, - id: "custom-template", - name: "Custom Template", - steps: [], - ...overrides, - }; -} - -async function createChannel(baseUrl, cwd) { - return (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Engineering", - status: "active", - boundPath: cwd, - allowedTools: ["read"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; -} - -async function setWorkspaceDefaultProvider(baseUrl, cwd, providerPresetId) { - assert.ok(providerPresetId, "workspace default test setup requires a channel model"); - await patchJson(`${baseUrl}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { - defaultProviderPresetId: providerPresetId, - }); -} - -async function createWorkflowTestChannel(baseUrl, cwd, name) { - return (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { - name, - status: "active", - boundPath: cwd, - allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], - runtimePolicy: { - maxConcurrentRuns: 1, - schedulingMode: "smart", - writeLockScope: "boundPath", - allowReadOnlyParallel: true, - queueOnConflict: true, - }, - })).channel; -} - -async function createAutoRouteTestChannel(baseUrl, cwd) { - const provider = (await createStaticChatProvider(baseUrl, cwd)).provider; - await postJson(`${baseUrl}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); - const channel = (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { - name: "Design", - status: "active", - boundPath: cwd, - defaultProviderPresetId: provider.id, - autoReplyMode: "auto_route", - allowedTools: ["read"], - })).channel; - const teammateId = "teammate_builtin_designer"; - await patchJson(`${baseUrl}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { - providerPresetId: provider.id, - }); - await putJson(`${baseUrl}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { - memberships: [{ channelId: channel.id }], - }); - return channel; -} - -async function createStaticChatProvider(baseUrl, cwd, overrides = {}) { - return await postJson(`${baseUrl}/providers?cwd=${encodeURIComponent(cwd)}`, { - kind: "glm", - displayName: "Static Chat", - providerId: "static-chat", - model: "static-model", - api: "google-generative-ai", - capabilities: ["chat"], - input: ["text"], - authHeader: false, - enabled: true, - ...overrides, - }); -} - -function simulateLegacyChannelWithoutModel(cwd, channelId) { - const store = createSqliteRunStore(cwd); - try { - store.updateChannel(channelId, { defaultProviderPresetId: null }, new Date().toISOString()); - } finally { - store.close(); - } -} - -async function postTaskRunAndReadReadyRunId(baseUrl, cwd, taskId, body) { - const response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}/run?cwd=${encodeURIComponent(cwd)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - const text = await response.text(); - const dataLine = text.split(/\r?\n/).find((line) => line.startsWith("data:") && line.includes("runId")); - assert.ok(dataLine, text); - return JSON.parse(dataLine.slice("data:".length).trim()).runId; -} - -function parseAcceptedPrivateSseEvents(text) { - const [accepted, ...events] = parseSseEvents(text); - assert.equal(accepted?.event, "accepted"); - const message = accepted.data.message; - assert.ok(message.id); - assert.ok(message.threadId); - assert.equal(message.authorType, "local-user"); - assert.equal(message.status, "ready"); - assert.deepEqual(events[0]?.data.message, message); - assert.equal(events[0]?.event, "ready"); - assert.equal(events.some(event => event.event === "accepted"), false); - return events; -} - -function privateReplyTextEvents(text) { - const events = parseAcceptedPrivateSseEvents(text); - for (const event of events.filter(event => event.event === "delta")) { - assert.equal(typeof event.data.delta, "string"); - assert.equal(typeof event.data.body, "string"); - assert.equal(event.data.messageId, events[0].data.aiReply.id); - } - // A real SDK message_end also publishes narration metadata with no new text. - return events.filter(event => event.event !== "delta" || event.data.delta !== ""); -} - -function parseSseEvents(text) { - return text - .split(/\n\n/) - .map((block) => { - const lines = block.split(/\r?\n/); - const eventLine = lines.find((line) => line.startsWith("event:")); - const dataLine = lines.find((line) => line.startsWith("data:")); - if (!eventLine || !dataLine) { - return undefined; - } - return { - event: eventLine.slice("event:".length).trim(), - data: projectCanonicalChannelConversationForTest(JSON.parse(dataLine.slice("data:".length).trim())), - }; - }) - .filter(Boolean); -} - -async function getJson(url) { - const response = await fetch(url); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return projectCanonicalChannelConversationForTest(await response.json()); -} - -async function getArtifactJson(baseUrl, cwd, artifactId) { - const response = await fetch(`${baseUrl}/artifacts/${encodeURIComponent(artifactId)}/content?cwd=${encodeURIComponent(cwd)}`); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return await response.json(); -} - -function assertCoordinatorWorkflowStartReply(result) { - assert.equal(result.aiReply.kind, "ai_reply"); - assert.equal(result.aiReply.threadId, result.message.threadId); - assert.equal(result.aiReply.authorId, "ling_agent"); - assert.doesNotMatch(result.aiReply.body, /Starting now|现在开始处理|workflow_start\./u); - const orchestration = result.autoStartedWorkflow?.orchestration; - assert.ok(orchestration, `coordinator startup must have a dispatch result; reply=${result.aiReply.status}: ${result.aiReply.body}`); - switch (orchestration.status) { - case "started": - assert.equal(result.aiReply.status, "ready"); - assert.match(result.aiReply.body, /^(?:Run started|Queued|运行已开始|已排队): .+/u); - assert.ok((orchestration.startedSteps ?? [orchestration]).some((step) => step.runId)); - break; - case "blocked": - case "failed": - assert.equal(result.aiReply.status, orchestration.status); - assert.ok(result.aiReply.body.includes(orchestration.blocker)); - break; - case "waiting_for_approval": - assert.equal(result.aiReply.status, "blocked"); - assert.match(result.aiReply.body, /^(?:Waiting for approval to continue\.|等待审批后继续。)$/u); - break; - case "idle": - assert.equal(result.aiReply.status, "ready"); - assert.ok(result.aiReply.body.includes(orchestration.reason)); - assert.match(result.aiReply.body, /^(?:Waiting:|等待:)/u); - break; - case "completed": - assert.equal(result.aiReply.status, "ready"); - assert.match(result.aiReply.body, /^(?:Workflow: Completed|工作流: 已完成)$/u); - break; - default: - assert.fail(`unasserted coordinator state: ${orchestration.status}`); - } - assert.doesNotMatch(result.aiReply.body, /主会话/); - assert.doesNotMatch(result.aiReply.body, /工作证据|折叠证据摘要/); -} - -function assertIntentDecisionInspectPayloadIsSafe(decision) { - const allowedKeys = new Set([ - "schemaVersion", - "id", - "workspaceId", - "channelId", - "sourceTurnId", - "sourceItemId", - "contextSegmentId", - "contextProjectionVersion", - "action", - "selectedTeammateIds", - "participantManifest", - "proposalId", - "resultItemIds", - "workflowId", - "reason", - "clarification", - "execution", - "status", - "createdBy", - "errorCode", - "createdAt", - "updatedAt", - ]); - const forbiddenKeys = [ - "prompt", - "body", - "messages", - "contextPack", - "systemPrompt", - "taskPrompt", - "proposal", - "rawPrompt", - "hiddenPrompt", - "privateMessages", - "artifactBody", - "providerSecret", - "providerSecrets", - "apiKey", - "apiKeyRef", - "secretRef", - "providerApiKey", - "confidence", - "score", - ]; - for (const key of Object.keys(decision)) { - assert.equal(allowedKeys.has(key), true, `unexpected intent inspect field: ${key}`); - } - for (const key of forbiddenKeys) { - assert.equal(Object.hasOwn(decision, key), false, `unsafe intent inspect field exposed: ${key}`); - } -} - -function assertSingleReplyIntentDecision(decisions, sourceItemId) { - assert.equal(decisions.length, 1); - assertIntentDecisionInspectPayloadIsSafe(decisions[0]); - assert.equal(decisions[0].schemaVersion, 2); - assert.equal(decisions[0].sourceItemId, sourceItemId); - assert.equal(decisions[0].action, "reply"); - assert.equal(decisions[0].status, "accepted"); -} - -async function waitForChannelMessageStatus(baseUrl, cwd, channelId, messageId, status, threadId) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - let latest; - while (Date.now() < deadline) { - latest = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/items?cwd=${encodeURIComponent(cwd)}${threadId ? `&threadId=${encodeURIComponent(threadId)}` : ""}`); - const message = latest.messages.find((item) => item.id === messageId); - if (message?.status === status) { - return latest; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`Timed out waiting for channel message ${messageId} to become ${status}. Latest: ${JSON.stringify(latest)}`); -} - -async function waitForCondition(predicate, label) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - while (Date.now() < deadline) { - if (await predicate()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`Timed out waiting for ${label}.`); -} - -async function waitForWorkflowStepStatus(baseUrl, cwd, workflowId, workflowStepId, status) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - let latest; - while (Date.now() < deadline) { - latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); - const step = latest.workflowSteps.find((candidate) => candidate.id === workflowStepId); - if (step?.status === status) { - return step; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`Timed out waiting for workflow step ${workflowStepId} to become ${status}. Latest: ${JSON.stringify(latest)}`); -} - -async function settleParallelDelayedWorkflow(baseUrl, cwd, workflowId, runner, text, options = {}) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - let latest; - while (Date.now() < deadline) { - runner.resolveAll(text); - latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); - const runningSteps = latest.workflowSteps.filter((step) => step.status === "running"); - const queuedSteps = options.drainQueued ? latest.workflowSteps.filter((step) => step.status === "queued") : []; - if (runningSteps.length === 0) { - if (queuedSteps.length > 0) { - await postJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); - continue; - } - await new Promise((resolve) => setTimeout(resolve, 50)); - const settled = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); - const hasRunning = settled.workflowSteps.some((step) => step.status === "running"); - const hasQueued = options.drainQueued && settled.workflowSteps.some((step) => step.status === "queued"); - if (!hasRunning && !hasQueued) { - return settled.workflowSteps; - } - continue; - } - await Promise.all(runningSteps.map((step) => waitForWorkflowStepStatus(baseUrl, cwd, workflowId, step.id, "succeeded"))); - } - assert.fail(`Timed out waiting for parallel delayed workflow ${workflowId} to settle. Latest: ${JSON.stringify(latest)}`); -} - -async function waitForWorkflowTemplateStepStatus(baseUrl, cwd, workflowId, templateStepId, status) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - let latest; - while (Date.now() < deadline) { - latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); - const step = latest.workflowSteps.find((candidate) => candidate.metadata?.templateStepId === templateStepId); - if (step?.status === status) { - return step; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.fail(`Timed out waiting for workflow template step ${templateStepId} to become ${status}. Latest: ${JSON.stringify(latest)}`); -} - -async function approveWorkflowStepGate(baseUrl, cwd, workflowId, templateStepId) { - const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; - let approval; - let step; - while (Date.now() < deadline) { - const steps = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); - step = steps.workflowSteps.find((candidate) => candidate.metadata?.templateStepId === templateStepId); - assert.ok(step, `Expected workflow step for template step ${templateStepId}`); - const approvals = await getJson(`${baseUrl}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(step.id)}`); - approval = approvals.approvals[0]; - if (approval) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - assert.ok(approval, `Expected pending approval for workflow step ${templateStepId}`); - assert.equal(approval.workflowStepId, step.id); - assert.equal(approval.actionType, "workflow_step"); - assert.equal(approval.payload.type, "workflow_step"); - assert.equal(approval.payload.workflowStepId, step.id); - const actions = await getJson(`${baseUrl}/execution-actions?cwd=${encodeURIComponent(cwd)}&status=waiting_for_approval`); - assert.equal(actions.actions.some((action) => action.approvalId === approval.id && action.targetRefs.some((ref) => ref.type === "workflow_step" && ref.id === step.id)), true); - await postJson(`${baseUrl}/approvals/${encodeURIComponent(approval.id)}/approve?cwd=${encodeURIComponent(cwd)}`, { - reason: `approve ${templateStepId}`, - }); - return await postJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); -} - -async function postJson(url, body) { - const preparedBody = await prepareChannelCreateBody(url, body); - await ensureGitRepoForChannelPost(url, preparedBody); - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(preparedBody), - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return projectCanonicalChannelConversationForTest(await response.json()); -} - -function projectCanonicalChannelConversationForTest(payload) { - if (!payload || typeof payload !== "object") return payload; - const decorations = Array.isArray(payload.decorations) ? payload.decorations : []; - const decorationByItemId = new Map(decorations.map((decoration) => [decoration.itemId, decoration])); - const projectItem = (item) => { - const decoration = decorationByItemId.get(item.id); - return { - ...projectConversationItemToChannelMessage(item, { - ...(decoration?.linkedRunId ? { linkedRunId: decoration.linkedRunId } : {}), - }), - ...(decoration?.editedAt ? { editedAt: decoration.editedAt } : {}), - ...(decoration?.pendingReplacement ? { pendingReplacement: decoration.pendingReplacement } : {}), - ...(decoration?.webEvidence ? { webEvidence: decoration.webEvidence } : {}), - }; - }; - if (payload.userItem) { - const message = projectItem(payload.userItem); - const aiReplies = (payload.assistantItems ?? []).map(projectItem); - return { - ...payload, - message, - aiReplies, - ...(aiReplies[0] ? { aiReply: aiReplies[0] } : {}), - }; - } - if (Array.isArray(payload.items)) { - return { - ...payload, - messages: payload.items - .filter((item) => item.kind === "user_message" || item.kind === "assistant_message" || item.kind === "system_event") - .map(projectItem), - }; - } - return payload; -} - -async function putJson(url, body) { - const response = await fetch(url, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return await response.json(); -} - -async function patchJson(url, body) { - const response = await fetch(url, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return await response.json(); -} - -async function deleteJson(url) { - const response = await fetch(url, { - method: "DELETE", - }); - if (!response.ok) { - assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); - } - return response; -} diff --git a/packages/daemon/test/host-text-write-bundle.test.mjs b/packages/daemon/test/host-text-write-bundle.test.mjs index 505a862be..e22575e0e 100644 --- a/packages/daemon/test/host-text-write-bundle.test.mjs +++ b/packages/daemon/test/host-text-write-bundle.test.mjs @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { runInNewContext } from "node:vm"; +import { compileFunction, runInNewContext } from "node:vm"; import test from "node:test"; import { parse } from "acorn"; import { ELECTRON_NODE_RESOURCE_PATHS, digestElectronNodeResource } from "../dist/runtime/sandbox/electron-node-resources.js"; @@ -49,10 +49,12 @@ async function packagingOptions(sourceMode) { const config = configs[0].value; const tsconfigRaw = runInNewContext(`(${inputCode.slice(config.start, config.end)})`); const emptyPlugin = { name: "host-write-bundle-mechanism-only", setup() {} }; - return runInNewContext(`(${code.slice(argument.start, argument.end)})`, { + const bindings = { desktopRoot, gstack: { plugin: emptyPlugin }, definitions: [], sourceInputs: sourceMode ? { entryPoint: "unused", tsconfigRaw, plugin: emptyPlugin } : undefined, - }); + }; + // esbuild validates plugin filters against its own realm's RegExp constructor. + return compileFunction(`return (${code.slice(argument.start, argument.end)})`, Object.keys(bindings))(...Object.values(bindings)); } for (const sourceMode of [false, true]) { diff --git a/packages/daemon/test/mcp-host-stdio-transport.test.mjs b/packages/daemon/test/mcp-host-stdio-transport.test.mjs index e236b2526..1315715a1 100644 --- a/packages/daemon/test/mcp-host-stdio-transport.test.mjs +++ b/packages/daemon/test/mcp-host-stdio-transport.test.mjs @@ -14,12 +14,99 @@ import { createUnifiedExecFixture } from "./support/unified-exec-fixture.mjs"; const dist = process.env.LING_MCP_STDIO_TEST_DIST ?? resolve(dirname(fileURLToPath(import.meta.url)), "../dist"); const load = path => import(pathToFileURL(join(dist, path)).href); const { createHostMcpStdioTransport } = await load("features/mcp/host-stdio-transport.js"); +const { BoundedMcpStdioReadBuffer } = await load("features/mcp/host-stdio-read-buffer.js"); const { reserveHostMcpOperation } = await load("features/mcp/host-operation.js"); const { AttemptHostOperationTracker } = await load("runtime/attempt-host-operation-tracker.js"); const MIB = 1024 * 1024; const LARGE_STDIO_BOUNDARY_TIMEOUT_MS = 45_000; const required = { kind: "user_input_required", requestId: "stdio-pause", requestVersion: 1 }; +test("fragmented stdout framing copies and scans a linear byte budget", t => { + const size = 8 * MIB; + const message = { jsonrpc: "2.0", method: "fixture/fragmented", params: { payload: "" } }; + message.params.payload = "x".repeat(size - Buffer.byteLength(JSON.stringify(message) + "\n")); + const bytes = Buffer.from(JSON.stringify(message) + "\n"); + const reader = new BoundedMcpStdioReadBuffer({ maxBufferSize: 64 * MIB }); + const concat = Buffer.concat, copy = Buffer.prototype.copy, indexOf = Buffer.prototype.indexOf; + let copiedBytes = 0, scannedBytes = 0, received; + Buffer.concat = function(chunks, length) { + copiedBytes += length ?? chunks.reduce((sum, chunk) => sum + chunk.length, 0); + return Reflect.apply(concat, this, [chunks, length]); + }; + Buffer.prototype.copy = function(...args) { + const copied = Reflect.apply(copy, this, args); + copiedBytes += copied; + return copied; + }; + Buffer.prototype.indexOf = function(value, offset = 0, encoding) { + scannedBytes += this.length - offset; + return Reflect.apply(indexOf, this, [value, offset, encoding]); + }; + try { + for (let offset = 0; offset < bytes.length; offset += 16 * 1024) { + reader.append(bytes.subarray(offset, offset + 16 * 1024)); + const next = reader.readMessage(); + if (next !== null) { + assert.equal(received, undefined); + received = next; + } + } + assert.equal(reader.readMessage(), null); + } finally { + Buffer.concat = concat; + Buffer.prototype.copy = copy; + Buffer.prototype.indexOf = indexOf; + reader.clear(); + } + assert.equal(received.params.payload.length, message.params.payload.length); + t.diagnostic(JSON.stringify({ frameBytes: bytes.length, chunkBytes: 16 * 1024, copiedBytes, scannedBytes })); + assert.ok(copiedBytes <= 3 * bytes.length, "fragmented input must not repeatedly copy the accumulated frame"); + assert.ok(scannedBytes <= bytes.length, "newline search must not rescan previously received bytes"); +}); + +test("bounded stdout framing preserves split UTF-8, CRLF, multiple messages and partial-tail compaction", () => { + const reader = new BoundedMcpStdioReadBuffer({ maxBufferSize: 256 }); + const first = { jsonrpc: "2.0", method: "fixture/first", params: { text: "x".repeat(128) } }; + const second = { jsonrpc: "2.0", method: "fixture/chinese", params: { text: "中文" } }; + const firstBytes = Buffer.from(JSON.stringify(first) + "\n"); + const secondBytes = Buffer.from(JSON.stringify(second) + "\r\n"); + const split = secondBytes.indexOf(Buffer.from("中")) + 1; + reader.append(Buffer.concat([firstBytes, secondBytes.subarray(0, 8)])); + assert.deepEqual(reader.readMessage(), first); + assert.equal(reader.readMessage(), null); + reader.append(secondBytes.subarray(8, split)); + assert.equal(reader.readMessage(), null); + reader.append(secondBytes.subarray(split)); + assert.deepEqual(reader.readMessage(), second); + assert.equal(reader.readMessage(), null); + reader.append(Buffer.concat([secondBytes, secondBytes])); + assert.deepEqual(reader.readMessage(), second); + assert.deepEqual(reader.readMessage(), second); + assert.equal(reader.readMessage(), null); +}); + +test("bounded stdout framing clears partial input on overflow and disposal without changing its byte limit", () => { + const maxBufferSize = 256; + const reader = new BoundedMcpStdioReadBuffer({ maxBufferSize }); + const message = { jsonrpc: "2.0", method: "fixture/boundary", params: { text: "" } }; + message.params.text = "x".repeat(maxBufferSize - Buffer.byteLength(JSON.stringify(message) + "\n")); + const bytes = Buffer.from(JSON.stringify(message) + "\n"); + reader.append(bytes.subarray(0, -1)); + assert.equal(reader.readMessage(), null); + reader.append(bytes.subarray(-1)); + assert.deepEqual(reader.readMessage(), message); + reader.append(bytes.subarray(0, -1)); + assert.equal(reader.readMessage(), null); + assert.throws(() => reader.append(Buffer.from("xx"))); + assert.equal(reader.readMessage(), null); + reader.append(bytes.subarray(0, 20)); + reader.clear(); + assert.equal(reader.readMessage(), null); + reader.append(bytes); + assert.deepEqual(reader.readMessage(), message); + assert.equal(reader.readMessage(), null); +}); + const serverScript = ` const fs = require('node:fs'); const readline = require('node:readline'); @@ -372,8 +459,15 @@ test("required closure within one multi-frame chunk suppresses the later message f.finish(); assertUnknown(f); }); -for (const invalid of ["not json\n", '{"jsonrpc":"2.0","method":7}\n']) { - test(`fatal framing seals callbacks and closes original process (${invalid.startsWith("not") ? "JSON" : "JSON-RPC"})`, { timeout: 10_000 }, async t => { +for (const [kind, invalid] of [ + ["JSON", "not json\n"], + ["JSON-RPC", '{"jsonrpc":"2.0","method":7}\n'], + ["non-protocol JSON", '{"not":"JSON-RPC"}\n'], + ["blank", "\n"], + ["CRLF-only", "\r\n"], + ["whitespace", " \t\n"], +]) { + test(`fatal framing seals callbacks and closes original process (${kind})`, { timeout: 10_000 }, async t => { const f = await fixture(t, { script: `process.stdout.write(${JSON.stringify(invalid)});process.stdin.resume();` }); await f.transport.start(); await until(() => f.errors.length === 1); await f.transport.close(); diff --git a/packages/daemon/test/recovery-discovery-fixture.test.mjs b/packages/daemon/test/recovery-discovery-fixture.test.mjs new file mode 100644 index 000000000..b5c4f96a7 --- /dev/null +++ b/packages/daemon/test/recovery-discovery-fixture.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readPreparedRunnerInput } from "../dist/features/runs/prepared-runner-input.js"; +import { createRecoveryDiscoveryFixture, DISCOVERY_CREATED_AT, DISCOVERY_DUE_AT, + seedScheduledBlockedPrefix } from "./test-support/recovery-discovery-fixture.mjs"; + +function recordMetadataWrites(t, store) { + const writes = []; + const prepare = store.database.prepare.bind(store.database); + t.mock.method(store.database, "prepare", (sql) => { + if (sql === "UPDATE ling_threads SET metadata_json = ? WHERE id = ?") writes.push(sql); + return prepare(sql); + }); + return writes; +} + +test("stale prefix coalesces metadata rebuilds while persisting all 500 Runs and lost Attempts", async (t) => { + const fixture = await createRecoveryDiscoveryFixture(t, "fixture-stale"); + const valid = await fixture.seedRun("conversation", "a-valid"); + const metadata = fixture.store.lingThreadMetadata; + const rebuild = metadata.rebuildLingThreadMetadata; + const writes = recordMetadataWrites(t, fixture.store); + fixture.seedBlockedStalePrefix(valid); + assert.equal(writes.length, 1, "one final derived projection, not one rebuild per source write"); + assert.equal(metadata.rebuildLingThreadMetadata, rebuild); + const before = fixture.store.getLingThread(valid.thread.id); + assert.equal(fixture.store.getRun(valid.run.id).status, "running"); + assert.deepEqual(before, fixture.store.rebuildLingThreadMetadata(valid.thread.id, DISCOVERY_CREATED_AT), + "batched projection must equal a fresh production rebuild before startup reconciliation"); + const { store } = fixture.reopenAfterRestart(); + assert.equal(store.database.prepare("SELECT COUNT(*) AS count FROM work_units").get().count, 501); + assert.equal(store.database.prepare("SELECT COUNT(*) AS count FROM run_sessions").get().count, 501); + const units = store.listWorkUnits({ kind: valid.unit.kind, status: "stale", limit: 500 }); + assert.equal(units.length, 500); + for (const unit of units) { + assert.match(unit.id, /^unit-z-blocked-conversation_run-\d{4}$/); + assert.equal(unit.ownerType, valid.unit.ownerType); + assert.equal(unit.ownerId, valid.unit.ownerId); + assert.equal(unit.taskId, valid.unit.taskId); + assert.equal(unit.activeAttemptId, `attempt-${unit.id.slice("unit-".length)}`); + assert.deepEqual(store.listWorkUnitAttempts(unit.id).map(attempt => attempt.status), ["lost"]); + const run = store.getRun(unit.linkedRunId); + assert.equal(run.status, "cancelled"); + assert.equal(run.workUnitId, unit.id); + assert.equal(run.threadId, valid.thread.id); + assert.deepEqual(run.requestSnapshot, valid.run.requestSnapshot); + } + const reopenedThread = store.getLingThread(valid.thread.id); + assert.deepEqual(reopenedThread, store.rebuildLingThreadMetadata(valid.thread.id, reopenedThread.metadata.rebuiltAt), + "batched projection must equal a fresh production rebuild after reopen"); +}); + +test("scheduled prefix coalesces metadata rebuilds without caching policy or frozen Pi inputs", async (t) => { + const fixture = await createRecoveryDiscoveryFixture(t, "fixture-scheduled"); + const metadata = fixture.store.lingThreadMetadata; + const rebuild = metadata.rebuildLingThreadMetadata; + const writes = recordMetadataWrites(t, fixture.store); + await seedScheduledBlockedPrefix(fixture); + assert.equal(writes.length, 500, "each distinct Thread receives its final projection once"); + assert.equal(metadata.rebuildLingThreadMetadata, rebuild); + const { store } = fixture.reopenAfterRestart(); + const continuations = store.listClaimableRunnerContinuations({ now: DISCOVERY_DUE_AT, scheduledOnly: true, limit: 500 }); + assert.equal(continuations.length, 500); + assert.equal(store.database.prepare("SELECT COUNT(*) AS count FROM work_units").get().count, 500); + assert.equal(store.database.prepare("SELECT COUNT(*) AS count FROM run_sessions").get().count, 500); + assert.equal(store.database.prepare("SELECT COUNT(*) AS count FROM work_unit_attempts").get().count, 1000); + const fingerprints = new Set(); + const threadIds = new Set(); + for (const continuation of continuations) { + assert.equal(continuation.state, "prepared"); + assert.equal(continuation.nextAttemptAt, DISCOVERY_DUE_AT); + const unit = store.getWorkUnit(continuation.workUnitId); + const run = store.getRun(continuation.runId); + assert.equal(unit.status, "queued"); + assert.equal(run.status, "cancelled"); + assert.equal(unit.linkedRunId, run.id); + assert.equal(run.workUnitId, unit.id); + assert.equal(unit.activeAttemptId, continuation.targetAttemptId); + assert.deepEqual(store.listWorkUnitAttempts(unit.id).map(attempt => attempt.status), ["lost", "created"]); + const turn = store.getLingTurnForRun(run.id); + assert.equal(unit.ownerId, turn.id); + assert.equal(turn.threadId, run.threadId); + assert.equal(turn.status, "cancelled"); + const execution = readPreparedRunnerInput(run.requestSnapshot.runnerExecutionInput, { cwd: run.cwd, task: run.taskSnapshot }); + assert.equal(execution.preparedInput.taskId, run.taskId); + assert.equal(run.requestSnapshot.effectivePolicySnapshot.workspaceAccessMode, "workspace_write"); + fingerprints.add(execution.preparedInput.fingerprint); + threadIds.add(run.threadId); + assert.deepEqual(store.getLingThread(run.threadId), store.rebuildLingThreadMetadata(run.threadId, DISCOVERY_CREATED_AT)); + } + assert.equal(fingerprints.size, 500, "every Run retains its own SDK-prepared task input"); + assert.equal(threadIds.size, 500); +}); + +test("stale prefix restores metadata rebuilding and leaves no transaction open when a source write throws", async (t) => { + const fixture = await createRecoveryDiscoveryFixture(t, "fixture-stale-throw"); + const valid = await fixture.seedRun("conversation", "a-valid"); + const metadata = fixture.store.lingThreadMetadata; + const rebuild = metadata.rebuildLingThreadMetadata; + const createRun = fixture.store.createRun.bind(fixture.store); + const failure = new Error("fixture-source-write-failed"); + const mocked = t.mock.method(fixture.store, "createRun", (input) => { + if (input.id.endsWith("0001")) throw failure; + return createRun(input); + }); + assert.throws(() => fixture.seedBlockedStalePrefix(valid), error => error === failure); + assert.equal(metadata.rebuildLingThreadMetadata, rebuild); + assert.equal(fixture.store.database.isTransaction, false); + mocked.mock.restore(); + const next = await fixture.seedRun("conversation", "after-failed-prefix"); + assert.equal(fixture.store.getLingThread(next.thread.id).metadata.lastTurnId, fixture.store.getLingTurnForRun(next.run.id).id); + const before = fixture.store.getLingThread(valid.thread.id); + assert.deepEqual(before, fixture.store.rebuildLingThreadMetadata(valid.thread.id, DISCOVERY_CREATED_AT)); +}); + +test("scheduled prefix restores metadata rebuilding after asynchronous input preparation rejects", async (t) => { + const fixture = await createRecoveryDiscoveryFixture(t, "fixture-scheduled-throw"); + const metadata = fixture.store.lingThreadMetadata; + const rebuild = metadata.rebuildLingThreadMetadata; + const seedRun = fixture.seedRun; + const failure = new Error("fixture-input-preparation-failed"); + const mocked = t.mock.method(fixture, "seedRun", async (...args) => { + if (args[1].endsWith("0001")) throw failure; + return seedRun(...args); + }); + await assert.rejects(seedScheduledBlockedPrefix(fixture), error => error === failure); + assert.equal(metadata.rebuildLingThreadMetadata, rebuild); + assert.equal(fixture.store.database.isTransaction, false); + mocked.mock.restore(); + const { store } = fixture.reopenAfterRestart(); + const continuations = store.listClaimableRunnerContinuations({ now: DISCOVERY_DUE_AT, scheduledOnly: true, limit: 500 }); + assert.equal(continuations.length, 1); + const run = store.getRun(continuations[0].runId); + assert.equal(run.status, "cancelled"); + assert.deepEqual(store.getLingThread(run.threadId), store.rebuildLingThreadMetadata(run.threadId, DISCOVERY_CREATED_AT)); +}); diff --git a/packages/daemon/test/runner-acceptance.test.mjs b/packages/daemon/test/runner-acceptance.test.mjs index 0d5c2cad3..ac631a407 100644 --- a/packages/daemon/test/runner-acceptance.test.mjs +++ b/packages/daemon/test/runner-acceptance.test.mjs @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import test from "node:test"; +import { pathToFileURL } from "node:url"; +import { readInstalledAcpSdkVersion } from "../dist/features/execution-engines/acp-sdk-version.js"; import { assessRunnerAcceptance } from "../dist/runtime/runner-acceptance.js"; const now = Date.parse("2026-09-08T00:00:00Z"); @@ -138,11 +142,46 @@ test("parent-thread inheritance cannot reuse task acceptance", async () => { } }); +test("ACP SDK manifest discovery follows root and nested entry layouts", t => { + const root = mkdtempSync(join(tmpdir(), "ling-acp sdk-version-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const [index, layout] of ["acp.js", "dist/acp.js", "dist/v1/stable/acp.js", "lib/deep/runtime/acp.js"].entries()) { + const directory = join(root, String(index)), entry = join(directory, layout), version = `2.${index}.0`; + mkdirSync(dirname(entry), { recursive: true }); + writeFileSync(entry, "export {};\n"); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "@agentclientprotocol/sdk", version })); + if (dirname(entry) !== directory) { + writeFileSync(join(dirname(entry), "package.json"), JSON.stringify({ name: "another-package", version: "999.0.0", type: "module" })); + } + assert.equal(readInstalledAcpSdkVersion(pathToFileURL(entry).href), version); + } +}); + +test("ACP SDK manifest discovery does not substitute unrelated package metadata", t => { + const directory = mkdtempSync(join(tmpdir(), "ling-acp-sdk-missing-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + writeFileSync(join(directory, "package.json"), JSON.stringify({ name: "@ling-stack/daemon", version: "999.0.0" })); + assert.throws(() => readInstalledAcpSdkVersion(pathToFileURL(join(directory, "dist/acp.js")).href), + /Unable to find @agentclientprotocol\/sdk manifest/); +}); + +test("ACP SDK manifest discovery fails closed on missing or invalid versions and malformed manifests", t => { + const directory = mkdtempSync(join(tmpdir(), "ling-acp-sdk-invalid-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const manifestPath = join(directory, "package.json"), entry = pathToFileURL(join(directory, "dist/v1/acp.js")).href; + for (const version of [undefined, "", " ", 2, null]) { + writeFileSync(manifestPath, JSON.stringify({ name: "@agentclientprotocol/sdk", version })); + assert.throws(() => readInstalledAcpSdkVersion(entry), /Invalid @agentclientprotocol\/sdk version/); + } + writeFileSync(manifestPath, "{"); + assert.throws(() => readInstalledAcpSdkVersion(entry), SyntaxError); +}); + for (const engineId of ["kimi-acp", "openclaw-acp"]) { test(`ACP admission for ${engineId} binds verification to the installed SDK version`, async () => { const { assertNativeExecutionAccepted } = await import("../dist/features/execution-engines/native-admission.js"); const { fixtureNativeAcceptance, fixtureNativePolicy } = await import("./support/fixture-native-acceptance.mjs"); - const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.resolve("@agentclientprotocol/sdk")), "utf8")); + const version = readInstalledAcpSdkVersion(); const input = { runRequest: { cwd: "/fixture", task: {}, executionBinding: { kind: "native_profile", engineId, profileId: "profile", profileRevision: 1, } }, store: {}, effectivePolicySnapshot: {}, signal: new AbortController().signal }; @@ -158,10 +197,14 @@ for (const engineId of ["kimi-acp", "openclaw-acp"]) { assert.ok(observed.length >= 2, "admission and final submission must both check verification"); for (const current of observed) assert.equal(current.sdkVersion, `@agentclientprotocol/sdk@${version}`); - for (const previousVersion of ["1.3.0", "1.8.0"]) { + for (const previousVersion of ["1.3.0", "1.8.0", "1.9.0"].filter(previousVersion => previousVersion !== version)) { const previousSdkEvidence = fixtureNativeAcceptance({ ...observed[0], sdkVersion: `@agentclientprotocol/sdk@${previousVersion}` }); await assert.rejects(assertNativeExecutionAccepted({ ...options, readAcceptanceEvidence: () => previousSdkEvidence }, input, profile, installation), error => error.code === "native_execution_verification_mismatch"); + let currentEvidence = fixtureNativeAcceptance(observed[0]); + const assertBeforeSubmission = await assertNativeExecutionAccepted({ ...options, readAcceptanceEvidence: () => currentEvidence }, input, profile, installation); + currentEvidence = previousSdkEvidence; + assert.throws(assertBeforeSubmission, error => error.code === "native_execution_verification_mismatch"); } }); } diff --git a/packages/daemon/test/support/channel-workspace-fixture.mjs b/packages/daemon/test/support/channel-workspace-fixture.mjs new file mode 100644 index 000000000..82fa20142 --- /dev/null +++ b/packages/daemon/test/support/channel-workspace-fixture.mjs @@ -0,0 +1,1309 @@ +import { appendChannelItemFixture, updateChannelItemFixture } from "../../../core/test/channel-conversation-fixture.mjs"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import test from "node:test"; +import { Document, Packer, Paragraph } from "docx"; +import { DEFAULT_WORKSPACE_ID, createSqliteRunStore, projectConversationItemToChannelMessage } from "../../../core/dist/index.js"; +import { createWorkflowParticipantAuthorityV1 } from "../../../contracts/dist/index.js"; +import { PiRunnerAdapter } from "../../../runner-pi/dist/index.js"; +import { createDaemonServer } from "../../dist/index.js"; +import { prepareChannelCreateBody } from "../conversation-model-test-fixture.mjs"; +import { ensureGitRepoForChannelPost } from "../git-test-helpers.mjs"; +import { commandOperationCandidate } from "./channel-operation-candidate-fixture.mjs"; +import { prepareFixtureTaskInput } from "./prepared-input-fixture.mjs"; +import { ToolFreePiRunner } from "./tool-free-pi-runner.mjs"; + +const require = createRequire(import.meta.url); + +const ASYNC_SETTLE_TIMEOUT_MS = 10_000; + +const TEST_RESTRICTED_NODE_PATH = realpathSync(process.execPath); + +const TEST_RESTRICTED_NODE_SHA256 = createHash("sha256") + .update(readFileSync(TEST_RESTRICTED_NODE_PATH)) + .digest("hex"); + +function withRestrictedCommandReadiness(config) { + return { + ...config, + testVerifiedPiRunner: true, + sandbox: { + ...config.sandbox, + restrictedCommands: { + // These orchestration tests never execute this helper. They need a + // Host-owned runtime identity so the fail-closed readiness gate can + // admit the typed plan before their synthetic Runner emits evidence. + inputHelper: { + path: TEST_RESTRICTED_NODE_PATH, + sha256: TEST_RESTRICTED_NODE_SHA256, + version: "channel-workspace-readiness-v1", + }, + executables: { + node: { + path: TEST_RESTRICTED_NODE_PATH, + sha256: TEST_RESTRICTED_NODE_SHA256, + }, + }, + }, + }, + }; +} + +class VerifiedExecutionTestRunner extends PiRunnerAdapter { + constructor(delegate) { + super(); + this.delegate = delegate; + } + + async prepareTaskInput(options) { + return this.delegate.prepareTaskInput?.(options) ?? prepareFixtureTaskInput(options); + } + + async runTask(options) { + return this.delegate.runTask(options); + } +} + +function projectionFor(item, assignment) { + return { + projectionVersion: `projection:${item.id}:${assignment.revision}`, + sourceStateFingerprint: `source-state:${item.id}:${assignment.revision}`, + threadId: item.threadId, + turnId: item.turnId, + segmentId: assignment.segmentId, + throughItemSequence: item.sequence, + summaryIds: [], + sourceRefs: [ + { type: "thread", id: item.threadId }, + { type: "turn", id: item.turnId }, + { type: "conversation_item", id: item.id }, + ], + policyVersion: "test-projector-v1", + visibilityFingerprint: "channel_shared", + }; +} + +async function readPdfFixtureBytes() { + const content = "BT\n/F1 24 Tf\n100 700 Td\n(Ling PDF fixture) Tj\nET"; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /MediaBox [0 0 612 792] /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`, + ]; + let pdf = "%PDF-1.4\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + pdf += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) + .join(""); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} + +async function readDocxFixtureBytes() { + const document = new Document({ + sections: [{ children: [new Paragraph("Walking on imported air")] }], + }); + return Buffer.from(await Packer.toBuffer(document)); +} + +class SummaryAwareRunner { + constructor() { + this.runs = []; + this.summaryRuns = []; + this.replyRuns = []; + this.failSummary = false; + } + + async runTask(options) { + this.runs.push(options); + const summary = /conversation summary coordinator/i.test(options.systemPrompt ?? ""); + if (summary && this.failSummary) { + this.summaryRuns.push(options); + throw new Error("synthetic summary provider failure"); + } + const text = summary + ? JSON.stringify({ + objective: "Preserve the durable historical objective.", + decisions: ["Use proactive summaries before provider rejection."], + constraints: ["Keep the recent raw tail."], + openQuestions: [], + }) + : "Reply after proactive summary."; + (summary ? this.summaryRuns : this.replyRuns).push(options); + options.onEvent?.({ type: "assistant_text_delta", text }); + return { + taskId: options.task.id, + messages: [{ role: "assistant", content: text }], + events: [], + }; + } +} + +class RecordingRunner extends ToolFreePiRunner { + constructor() { + super(); + this.runs = []; + } + + async runTask(options) { + this.runs.push(options); + return super.runTask(options, () => "Designer reply"); + } +} + +class WorkflowEvidenceRunner { + constructor() { + this.runs = []; + } + + async runTask(options) { + this.runs.push(options); + const toolCallId = `workflow_read_${this.runs.length}`; + options.onEvent?.({ + type: "tool_start", + toolCallId, + toolName: "read", + args: { path: "package.json" }, + }); + options.onEvent?.({ + type: "tool_end", + toolCallId, + toolName: "read", + result: { path: "package.json", content: [{ type: "text", text: "fixture" }] }, + isError: false, + }); + options.onEvent?.({ type: "assistant_text_delta", text: "Designer reply" }); + return { + taskId: options.task.id, + messages: ["Designer reply"], + events: [], + }; + } +} + +class DelayedCommandEvidenceRunner { + constructor(command) { + this.command = command; + this.executionRuns = []; + this.selectorRuns = []; + this.reportRuns = []; + this.pendingExecution = undefined; + } + + async runTask(options) { + if (/terminal report synthesizer/i.test(options.systemPrompt ?? "")) { + this.reportRuns.push(options); + options.onEvent?.({ type: "assistant_text_delta", text: "{}" }); + return { taskId: options.task.id, messages: ["{}"], events: [] }; + } + if (/channel intent selector/i.test(options.systemPrompt ?? "")) { + this.selectorRuns.push(options); + const text = JSON.stringify({ + action: "execute", + reason: "The user explicitly requested one bounded command execution.", + execution: this.selectorExecution ?? { schemaVersion: 2, + operations: [commandOperationCandidate({ sourceQuote: this.command, command: this.command })] }, + }); + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } + this.executionRuns.push(options); + await new Promise((resolveExecution) => { + this.pendingExecution = resolveExecution; + }); + const toolCallId = "single_participant_node"; + options.onEvent?.({ + type: "tool_start", + toolCallId, + toolName: "bash", + args: { command: this.command }, + }); + options.onEvent?.({ + type: "tool_end", + toolCallId, + toolName: "bash", + result: { + content: [{ type: "text", text: "proof:7319" }], + details: { + unifiedExec: { + status: "succeeded", + cwd: options.cwd, + command: { kind: "shell", command: this.command, shell: "bash" }, + startedAt: "2026-11-02T00:00:00.000Z", + endedAt: "2026-11-02T00:00:00.000Z", + durationMs: 0, + exitCode: 0, + timedOut: false, + cancelled: false, + stdout: { byteCount: 10, preview: "proof:7319", truncated: false }, + stderr: { byteCount: 0, preview: "", truncated: false }, + artifacts: [], + }, + }, + }, + isError: false, + }); + const text = "Verified proof:7319."; + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } + + releaseExecution() { + this.pendingExecution?.(); + this.pendingExecution = undefined; + } +} + +class MismatchedExactCommandRunner { + constructor() { + this.decisions = []; + this.executionRuns = []; + } + + async runTask(options) { + if (/terminal report synthesizer/i.test(options.systemPrompt ?? "")) { + options.onEvent?.({ type: "assistant_text_delta", text: "{}" }); + return { taskId: options.task.id, messages: ["{}"], events: [] }; + } + if (/channel intent selector/i.test(options.systemPrompt ?? "")) { + const text = JSON.stringify({ + action: "execute", + reason: "The user explicitly requested one bounded command execution.", + execution: { schemaVersion: 2, operations: [commandOperationCandidate({ + sourceQuote: "@QA 执行 `node app.js` 验证,并且必须提供真实 evidence。", command: "node app.js", + owner: { participantId: "teammate_builtin_qa", sourceQuote: "QA" } })] }, + }); + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } + + this.executionRuns.push(options); + const toolCallId = "mismatched_exact_command"; + const input = { command: "node -e \"console.log('semantic substitute')\"" }; + options.onEvent?.({ type: "tool_start", toolCallId, toolName: "bash", args: input }); + const decision = await options.onApprovalRequired?.({ + toolCallId, + toolName: "bash", + input, + task: options.task, + cwd: options.cwd, + }); + this.decisions.push(decision); + options.onEvent?.({ + type: "tool_end", + toolCallId, + toolName: "bash", + result: { approved: decision?.approved, reason: decision?.reason }, + isError: !decision?.approved, + }); + const text = decision?.approved ? "Unexpectedly executed substitute command." : "Blocked: exact command binding did not match."; + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } +} + +class DelayedFileCommandRunner { + constructor() { + this.executionRuns = []; + this.selectorRuns = []; + this.pendingResolvers = []; + } + + async runTask(options) { + if (/channel intent selector/i.test(options.systemPrompt ?? "")) { + this.selectorRuns.push(options); + const text = JSON.stringify({ + action: "execute", + reason: "The user explicitly requested a file write followed by a Node verification.", + execution: this.selectorExecution, + }); + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } + this.executionRuns.push(options); + await new Promise((resolveExecution) => this.pendingResolvers.push(resolveExecution)); + const text = "Execution released for cleanup."; + options.onEvent?.({ type: "assistant_text_delta", text }); + return { taskId: options.task.id, messages: [text], events: [] }; + } + + releaseAll() { + this.pendingResolvers.splice(0).forEach((resolveExecution) => resolveExecution()); + } +} + +class DirectToolRunner { + constructor() { + this.runs = []; + } + + async runTask(options) { + this.runs.push(options); + options.onEvent?.({ + type: "tool_start", + toolCallId: "direct_read_package", + toolName: "read", + args: { path: "package.json" }, + }); + options.onEvent?.({ + type: "tool_end", + toolCallId: "direct_read_package", + toolName: "read", + result: { path: "package.json", content: [{ type: "text", text: "{\"name\":\"ling-stack\"}" }] }, + isError: false, + }); + options.onEvent?.({ type: "assistant_text_delta", text: "Read package.json successfully." }); + return { + taskId: options.task.id, + messages: [{ role: "assistant", content: "Read package.json successfully." }], + events: [], + }; + } +} + +class DirectApprovalRunner { + constructor() { + this.runs = []; + this.decisions = []; + } + + async runTask(options) { + this.runs.push(options); + options.onEvent?.({ + type: "tool_start", + toolCallId: "direct_private_bash", + toolName: "bash", + args: { command: "printf private-approved" }, + }); + const decision = await options.onApprovalRequired?.({ + toolCallId: "direct_private_bash", + toolName: "bash", + input: { command: "printf private-approved" }, + task: options.task, + cwd: options.cwd, + defaultApproval: "ask", + }); + assert.ok(decision); + this.decisions.push(decision); + options.onEvent?.({ + type: "tool_end", + toolCallId: "direct_private_bash", + toolName: "bash", + result: { approved: decision.approved }, + isError: !decision.approved, + }); + if (!decision.approved) { + throw new Error(decision.reason ?? "Private shell approval denied."); + } + const text = "Private shell check completed after approval."; + options.onEvent?.({ type: "assistant_text_delta", text }); + return { + taskId: options.task.id, + messages: [{ role: "assistant", content: text }], + events: [], + }; + } +} + +class PromptEchoRunner extends ToolFreePiRunner { + constructor() { + super(); + this.runs = []; + } + + async runTask(options) { + return super.runTask(options, () => { + this.runs.push(options); + return options.task.prompt; + }); + } +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +class ScriptedRunner extends ToolFreePiRunner { + constructor(responses) { + super(); + this.responses = responses.slice(); + this.runs = []; + } + + async runTask(options) { + return super.runTask(options, () => { + this.runs.push(options); + const queuedResponse = this.responses.shift(); + const response = typeof queuedResponse === "function" ? queuedResponse(options) : queuedResponse; + if (response instanceof Error) throw response; + return response ?? ""; + }); + } +} + +class SelectiveFailingRunner extends ToolFreePiRunner { + constructor(failingTitles) { + super(); + this.failingTitles = new Set(failingTitles); + this.runs = []; + } + + async runTask(options) { + return super.runTask(options, () => { + this.runs.push(options); + if (this.failingTitles.has(options.task.title)) throw new Error(`${options.task.title} failed by test runner`); + return `${options.task.title} complete`; + }); + } +} + +class DelayedRunner extends ToolFreePiRunner { + constructor() { + super(); + this.runs = []; + this.completed = false; + this.pending = undefined; + } + + async runTask(options) { + return super.runTask(options, async () => { + this.runs.push(options); + const text = await new Promise((resolve) => { + this.pending = resolve; + }); + this.completed = true; + return text; + }); + } + + resolve(text) { + this.pending?.(text); + this.pending = undefined; + } +} + +class AbortableDelayedRunner extends ToolFreePiRunner { + constructor() { + super(); + this.runs = []; + this.abortCount = 0; + this.pending = undefined; + } + + async runTask(options) { + return super.runTask(options, () => { + this.runs.push(options); + return new Promise((resolve, reject) => { + this.pending = { resolve, reject }; + options.signal?.addEventListener("abort", () => { + this.abortCount += 1; + reject(new Error("aborted by test")); + }, { once: true }); + }); + }); + } + + resolve(text) { + this.pending?.resolve(text); + this.pending = undefined; + } +} + +function readCurrentChannelUserMessage(prompt) { + const matches = [...prompt.matchAll(/User message:\nTurn envelope:\nText:\n([^\n]+)/g)]; + return matches.at(-1)?.[1]; +} + +class ParallelDelayedRunner extends ToolFreePiRunner { + constructor() { + super(); + this.runs = []; + this.pendingResolvers = []; + this.startedResolvers = []; + this.autoResolveText = undefined; + } + + async runTask(options) { + this.runs.push(options); + this.startedResolvers.splice(0).forEach((resolveStarted) => resolveStarted()); + return super.runTask(options, async () => { + return this.autoResolveText ?? new Promise((resolve) => this.pendingResolvers.push(resolve)); + }); + } + + async waitUntilStarted(count) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (this.runs.length >= count) { + return; + } + await new Promise((resolve) => { + this.startedResolvers.push(resolve); + setTimeout(resolve, 20); + }); + } + assert.fail(`Timed out waiting for ${count} parallel runs. Started: ${this.runs.length}`); + } + + resolveAll(text) { + this.autoResolveText = text; + this.pendingResolvers.splice(0).forEach((resolvePending) => resolvePending(text)); + } +} + +function testDaemonConfig(cwd) { + return { + host: "127.0.0.1", + port: 0, + maxBodyBytes: 1024 * 1024, + defaultWorkspaceCwd: cwd, + runners: {}, + runtimeTools: {}, + channels: { autoRoute: false, intentSelector: false, terminalReports: false }, + }; +} + +function unusedRunnerRegistry() { + return { + get() { + return undefined; + }, + list() { + return []; + }, + resolve() { + throw new Error("runner registry should not be used by deterministic route test"); + }, + }; +} + +async function assertInvalidModelPlan(baseUrl, cwd, channelId, sourceItemId, field) { + const decisions = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/intent-decisions?cwd=${encodeURIComponent(cwd)}&sourceItemId=${encodeURIComponent(sourceItemId)}`); + assert.equal(decisions.decisions.length, 1); + const decision = decisions.decisions[0]; + assert.equal(decision.action, "reply", "failed Host diagnostic only, not an accepted ordinary reply"); + assert.equal(decision.status, "failed"); + assert.equal(decision.errorCode, "channel_operation_plan_invalid"); + assert.equal(decision.createdBy, "model_policy"); + assert.equal(decision.execution, undefined); + const observations = await getJson(`${baseUrl}/control-plane/events?cwd=${encodeURIComponent(cwd)}&domains=runners&limit=1000`); + const rejected = observations.events.filter(event => event.entityType === "channel_intent_selection" + && event.entityId === sourceItemId && event.data.outcome === "binding_rejected"); + assert.deepEqual(rejected.map(event => event.data.fields), [[field]]); + const owned = observations.events.filter(event => event.entityType === "channel_intent_selection" && event.entityId === sourceItemId); + assert.deepEqual(owned.filter(event => event.action === "channel_intent.request_started").map(event => + [event.data.requestOrdinal, event.data.purpose]), [[1, "initial"], [2, "binding_repair"]]); + // ScriptedRunner has one response: its repair deliberately returns empty + // text. Keep that parse failure distinct from a second binding rejection. + assert.deepEqual(owned.filter(event => event.data.outcome === "intent_schema_rejected").map(event => event.data.requestOrdinal), [2]); +} + +async function startDaemonForTest(runner, dependencies = {}, config = {}) { + const { channels, testVerifiedPiRunner, ...restConfig } = config; + const executionRunner = testVerifiedPiRunner && runner + ? new VerifiedExecutionTestRunner(runner) + : runner; + const routedRunner = wrapTestRunnerContextSegmentation(executionRunner); + const daemon = createDaemonServer({ + host: "127.0.0.1", + port: 0, + ...restConfig, + channels: { intentSelector: false, terminalReports: false, ...channels }, + }, { + ...dependencies, + ...(routedRunner ? { runner: routedRunner } : {}), + }); + await daemon.start(); + const address = daemon.server.address(); + assert.ok(address && typeof address === "object"); + return { + url: `http://127.0.0.1:${address.port}`, + server: daemon.server, + close: () => daemon.stop(), + }; +} + +async function publishHostOpenAvailability(baseUrl, bridgeToken) { + const response = await fetch(new URL("/desktop/capabilities/host/availability", baseUrl), { + method: "POST", + headers: { + "content-type": "application/json", + "x-ling-desktop-bridge-token": bridgeToken, + }, + body: JSON.stringify({ + protocol: "ling-host-capability-bridge/v1", + desktopInstanceId: "desktop-channel-test", + generation: 1, + capabilities: ["host.open_local_target"], + checkedAt: new Date().toISOString(), + }), + }); + assert.equal(response.status, 200, await response.text()); +} + +function wrapTestRunnerContextSegmentation(runner) { + if (!runner) { + return undefined; + } + runner.segmentationRuns ??= []; + const routed = Object.create(runner); + routed.prepareTaskInput = runner.prepareTaskInput?.bind(runner) ?? prepareFixtureTaskInput; + routed.runTask = async (options) => { + if (!/context segmentation policy proposer/i.test(options.systemPrompt ?? "")) { + return runner.runTask(options); + } + runner.segmentationRuns.push(options); + const configuredResponse = runner.segmentationResponses?.shift(); + const text = typeof configuredResponse === "function" + ? configuredResponse(options) + : configuredResponse ?? JSON.stringify({ action: "uncertain", candidateSegmentIds: [] }); + options.onEvent?.({ type: "assistant_text_delta", text }); + return { + taskId: options.task.id, + messages: [text], + events: [], + }; + }; + return routed; +} + +function createParallelWorkflowStep(store, workflowId, title, metadata = {}) { + return store.createChannelWorkflowStep( + { + workflowId, + position: metadata.position ?? 1, + title, + prompt: `Complete ${title}.`, + toParticipantId: "teammate_builtin_engineer", + toParticipantLabel: "Engineer", + participantAuthority: createWorkflowParticipantAuthorityV1({ + kind: "workflow_definition", + origin: "workflow_graph", + participantId: "teammate_builtin_engineer", + sourceId: workflowId, + }), + status: metadata.status, + metadata: { + templateStepId: title.toLowerCase().replaceAll(" ", "-"), + branchGroupId: metadata.branchGroupId, + joinGroupId: metadata.joinGroupId, + dependsOnStepIds: metadata.dependsOnStepIds, + dependencyMode: metadata.dependencyMode, + fanInPolicy: metadata.fanInPolicy, + autoStart: metadata.autoStart, + maxParallelSiblings: metadata.maxParallelSiblings, + providerPresetId: metadata.providerPresetId, + requiredTools: metadata.omitRequiredTools ? undefined : metadata.requiredTools ?? ["read"], + approvalGate: metadata.approvalGate, + }, + }, + new Date().toISOString(), + ); +} + +function createStoreChannelAndMessage(store, cwd, body) { + const now = "2026-06-11T00:00:00.000Z"; + const channel = store.createChannel({ + name: "Ling Agent Session", + status: "active", + boundPath: cwd, + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + }, now); + const message = appendChannelItemFixture(store, { + channelId: channel.id, + authorType: "local-user", + body, + }, now); + return { channel, message }; +} + +function createActiveChannelReplyRunFixture(store, input) { + const teammateId = input.teammateId ?? "ling_agent"; + const sourceMessage = appendChannelItemFixture(store, { + channelId: input.channelId, + kind: "message", + intent: "discuss", + authorType: "local-user", + body: "Source message for the active channel reply.", + status: "ready", + }, "2026-06-11T00:00:55.000Z"); + const replyMessage = appendChannelItemFixture(store, { + channelId: input.channelId, + replyToItemId: sourceMessage.id, + kind: "ai_reply", + intent: "discuss", + authorType: "ai-teammate", + authorId: teammateId, + body: "", + status: "pending", + }, "2026-06-11T00:00:56.000Z"); + const workUnit = store.createWorkUnit({ + id: `wu_${input.runId}`, + workspaceId: DEFAULT_WORKSPACE_ID, + kind: "channel_ai_reply", + ownerType: "channel_message", + ownerId: sourceMessage.id, + channelId: input.channelId, + teammateId, + idempotencyKey: `channel-reply-fixture:${input.runId}`, + createdByActor: "system", + request: { sourceMessageId: sourceMessage.id }, + linkedMessageId: replyMessage.id, + now: "2026-06-11T00:00:57.000Z", + }); + const run = store.createRun({ + id: input.runId, + cwd: input.cwd, + taskId: input.taskId, + channelId: input.channelId, + threadId: sourceMessage.threadId, + teammateId, + providerName: "test-provider", + providerModel: "test-model", + taskSnapshot: { id: input.taskId, title: "Active work" }, + providerSnapshot: { provider: "test-provider", model: "test-model" }, + requestSnapshot: { + cwd: input.cwd, + sourceMessageId: sourceMessage.id, + linkedMessageId: replyMessage.id, + }, + workUnitId: workUnit.id, + createdAt: "2026-06-11T00:01:00.000Z", + }); + store.linkRunToWorkUnit(run.id, workUnit.id, "2026-06-11T00:01:30.000Z"); + updateChannelItemFixture(store, replyMessage.id, { linkedRunId: run.id }, "2026-06-11T00:01:31.000Z"); + store.markRunStarted(run.id, "2026-06-11T00:02:00.000Z"); + return store.getRun(run.id); +} + +function customTemplate(overrides = {}) { + return { + schemaVersion: 1, + id: "custom-template", + name: "Custom Template", + steps: [], + ...overrides, + }; +} + +async function createChannel(baseUrl, cwd) { + return (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Engineering", + status: "active", + boundPath: cwd, + allowedTools: ["read"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; +} + +async function setWorkspaceDefaultProvider(baseUrl, cwd, providerPresetId) { + assert.ok(providerPresetId, "workspace default test setup requires a channel model"); + await patchJson(`${baseUrl}/workspace-settings?cwd=${encodeURIComponent(cwd)}`, { + defaultProviderPresetId: providerPresetId, + }); +} + +async function createWorkflowTestChannel(baseUrl, cwd, name) { + return (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { + name, + status: "active", + boundPath: cwd, + allowedTools: ["read", "grep", "find", "ls", "bash", "edit", "write"], + runtimePolicy: { + maxConcurrentRuns: 1, + schedulingMode: "smart", + writeLockScope: "boundPath", + allowReadOnlyParallel: true, + queueOnConflict: true, + }, + })).channel; +} + +async function createAutoRouteTestChannel(baseUrl, cwd) { + const provider = (await createStaticChatProvider(baseUrl, cwd)).provider; + await postJson(`${baseUrl}/providers/${encodeURIComponent(provider.id)}/check?cwd=${encodeURIComponent(cwd)}`); + const channel = (await postJson(`${baseUrl}/channels?cwd=${encodeURIComponent(cwd)}`, { + name: "Design", + status: "active", + boundPath: cwd, + defaultProviderPresetId: provider.id, + autoReplyMode: "auto_route", + allowedTools: ["read"], + })).channel; + const teammateId = "teammate_builtin_designer"; + await patchJson(`${baseUrl}/teammates/${encodeURIComponent(teammateId)}?cwd=${encodeURIComponent(cwd)}`, { + providerPresetId: provider.id, + }); + await putJson(`${baseUrl}/teammates/${encodeURIComponent(teammateId)}/channels?cwd=${encodeURIComponent(cwd)}`, { + memberships: [{ channelId: channel.id }], + }); + return channel; +} + +async function createStaticChatProvider(baseUrl, cwd, overrides = {}) { + return await postJson(`${baseUrl}/providers?cwd=${encodeURIComponent(cwd)}`, { + kind: "glm", + displayName: "Static Chat", + providerId: "static-chat", + model: "static-model", + api: "google-generative-ai", + capabilities: ["chat"], + input: ["text"], + authHeader: false, + enabled: true, + ...overrides, + }); +} + +function simulateLegacyChannelWithoutModel(cwd, channelId) { + const store = createSqliteRunStore(cwd); + try { + store.updateChannel(channelId, { defaultProviderPresetId: null }, new Date().toISOString()); + } finally { + store.close(); + } +} + +async function postTaskRunAndReadReadyRunId(baseUrl, cwd, taskId, body) { + const response = await fetch(`${baseUrl}/tasks/${encodeURIComponent(taskId)}/run?cwd=${encodeURIComponent(cwd)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + const text = await response.text(); + const dataLine = text.split(/\r?\n/).find((line) => line.startsWith("data:") && line.includes("runId")); + assert.ok(dataLine, text); + return JSON.parse(dataLine.slice("data:".length).trim()).runId; +} + +function parseAcceptedPrivateSseEvents(text) { + const [accepted, ...events] = parseSseEvents(text); + assert.equal(accepted?.event, "accepted"); + const message = accepted.data.message; + assert.ok(message.id); + assert.ok(message.threadId); + assert.equal(message.authorType, "local-user"); + assert.equal(message.status, "ready"); + assert.deepEqual(events[0]?.data.message, message); + assert.equal(events[0]?.event, "ready"); + assert.equal(events.some(event => event.event === "accepted"), false); + return events; +} + +function privateReplyTextEvents(text) { + const events = parseAcceptedPrivateSseEvents(text); + for (const event of events.filter(event => event.event === "delta")) { + assert.equal(typeof event.data.delta, "string"); + assert.equal(typeof event.data.body, "string"); + assert.equal(event.data.messageId, events[0].data.aiReply.id); + } + // A real SDK message_end also publishes narration metadata with no new text. + return events.filter(event => event.event !== "delta" || event.data.delta !== ""); +} + +function parseSseEvents(text) { + return text + .split(/\n\n/) + .map((block) => { + const lines = block.split(/\r?\n/); + const eventLine = lines.find((line) => line.startsWith("event:")); + const dataLine = lines.find((line) => line.startsWith("data:")); + if (!eventLine || !dataLine) { + return undefined; + } + return { + event: eventLine.slice("event:".length).trim(), + data: projectCanonicalChannelConversationForTest(JSON.parse(dataLine.slice("data:".length).trim())), + }; + }) + .filter(Boolean); +} + +async function getJson(url) { + const response = await fetch(url); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return projectCanonicalChannelConversationForTest(await response.json()); +} + +async function getArtifactJson(baseUrl, cwd, artifactId) { + const response = await fetch(`${baseUrl}/artifacts/${encodeURIComponent(artifactId)}/content?cwd=${encodeURIComponent(cwd)}`); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return await response.json(); +} + +function assertCoordinatorWorkflowStartReply(result) { + assert.equal(result.aiReply.kind, "ai_reply"); + assert.equal(result.aiReply.threadId, result.message.threadId); + assert.equal(result.aiReply.authorId, "ling_agent"); + assert.doesNotMatch(result.aiReply.body, /Starting now|现在开始处理|workflow_start\./u); + const orchestration = result.autoStartedWorkflow?.orchestration; + assert.ok(orchestration, `coordinator startup must have a dispatch result; reply=${result.aiReply.status}: ${result.aiReply.body}`); + switch (orchestration.status) { + case "started": + assert.equal(result.aiReply.status, "ready"); + assert.match(result.aiReply.body, /^(?:Run started|Queued|运行已开始|已排队): .+/u); + assert.ok((orchestration.startedSteps ?? [orchestration]).some((step) => step.runId)); + break; + case "blocked": + case "failed": + assert.equal(result.aiReply.status, orchestration.status); + assert.ok(result.aiReply.body.includes(orchestration.blocker)); + break; + case "waiting_for_approval": + assert.equal(result.aiReply.status, "blocked"); + assert.match(result.aiReply.body, /^(?:Waiting for approval to continue\.|等待审批后继续。)$/u); + break; + case "idle": + assert.equal(result.aiReply.status, "ready"); + assert.ok(result.aiReply.body.includes(orchestration.reason)); + assert.match(result.aiReply.body, /^(?:Waiting:|等待:)/u); + break; + case "completed": + assert.equal(result.aiReply.status, "ready"); + assert.match(result.aiReply.body, /^(?:Workflow: Completed|工作流: 已完成)$/u); + break; + default: + assert.fail(`unasserted coordinator state: ${orchestration.status}`); + } + assert.doesNotMatch(result.aiReply.body, /主会话/); + assert.doesNotMatch(result.aiReply.body, /工作证据|折叠证据摘要/); +} + +function assertIntentDecisionInspectPayloadIsSafe(decision) { + const allowedKeys = new Set([ + "schemaVersion", + "id", + "workspaceId", + "channelId", + "sourceTurnId", + "sourceItemId", + "contextSegmentId", + "contextProjectionVersion", + "action", + "selectedTeammateIds", + "participantManifest", + "proposalId", + "resultItemIds", + "workflowId", + "reason", + "clarification", + "execution", + "status", + "createdBy", + "errorCode", + "createdAt", + "updatedAt", + ]); + const forbiddenKeys = [ + "prompt", + "body", + "messages", + "contextPack", + "systemPrompt", + "taskPrompt", + "proposal", + "rawPrompt", + "hiddenPrompt", + "privateMessages", + "artifactBody", + "providerSecret", + "providerSecrets", + "apiKey", + "apiKeyRef", + "secretRef", + "providerApiKey", + "confidence", + "score", + ]; + for (const key of Object.keys(decision)) { + assert.equal(allowedKeys.has(key), true, `unexpected intent inspect field: ${key}`); + } + for (const key of forbiddenKeys) { + assert.equal(Object.hasOwn(decision, key), false, `unsafe intent inspect field exposed: ${key}`); + } +} + +function assertSingleReplyIntentDecision(decisions, sourceItemId) { + assert.equal(decisions.length, 1); + assertIntentDecisionInspectPayloadIsSafe(decisions[0]); + assert.equal(decisions[0].schemaVersion, 2); + assert.equal(decisions[0].sourceItemId, sourceItemId); + assert.equal(decisions[0].action, "reply"); + assert.equal(decisions[0].status, "accepted"); +} + +async function waitForChannelMessageStatus(baseUrl, cwd, channelId, messageId, status, threadId) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + let latest; + while (Date.now() < deadline) { + latest = await getJson(`${baseUrl}/channels/${encodeURIComponent(channelId)}/items?cwd=${encodeURIComponent(cwd)}${threadId ? `&threadId=${encodeURIComponent(threadId)}` : ""}`); + const message = latest.messages.find((item) => item.id === messageId); + if (message?.status === status) { + return latest; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`Timed out waiting for channel message ${messageId} to become ${status}. Latest: ${JSON.stringify(latest)}`); +} + +async function waitForCondition(predicate, label) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`Timed out waiting for ${label}.`); +} + +async function waitForWorkflowStepStatus(baseUrl, cwd, workflowId, workflowStepId, status) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + let latest; + while (Date.now() < deadline) { + latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); + const step = latest.workflowSteps.find((candidate) => candidate.id === workflowStepId); + if (step?.status === status) { + return step; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`Timed out waiting for workflow step ${workflowStepId} to become ${status}. Latest: ${JSON.stringify(latest)}`); +} + +async function settleParallelDelayedWorkflow(baseUrl, cwd, workflowId, runner, text, options = {}) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + let latest; + while (Date.now() < deadline) { + runner.resolveAll(text); + latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); + const runningSteps = latest.workflowSteps.filter((step) => step.status === "running"); + const queuedSteps = options.drainQueued ? latest.workflowSteps.filter((step) => step.status === "queued") : []; + if (runningSteps.length === 0) { + if (queuedSteps.length > 0) { + await postJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); + continue; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + const settled = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); + const hasRunning = settled.workflowSteps.some((step) => step.status === "running"); + const hasQueued = options.drainQueued && settled.workflowSteps.some((step) => step.status === "queued"); + if (!hasRunning && !hasQueued) { + return settled.workflowSteps; + } + continue; + } + await Promise.all(runningSteps.map((step) => waitForWorkflowStepStatus(baseUrl, cwd, workflowId, step.id, "succeeded"))); + } + assert.fail(`Timed out waiting for parallel delayed workflow ${workflowId} to settle. Latest: ${JSON.stringify(latest)}`); +} + +async function waitForWorkflowTemplateStepStatus(baseUrl, cwd, workflowId, templateStepId, status) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + let latest; + while (Date.now() < deadline) { + latest = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); + const step = latest.workflowSteps.find((candidate) => candidate.metadata?.templateStepId === templateStepId); + if (step?.status === status) { + return step; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`Timed out waiting for workflow template step ${templateStepId} to become ${status}. Latest: ${JSON.stringify(latest)}`); +} + +async function approveWorkflowStepGate(baseUrl, cwd, workflowId, templateStepId) { + const deadline = Date.now() + ASYNC_SETTLE_TIMEOUT_MS; + let approval; + let step; + while (Date.now() < deadline) { + const steps = await getJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/steps?cwd=${encodeURIComponent(cwd)}`); + step = steps.workflowSteps.find((candidate) => candidate.metadata?.templateStepId === templateStepId); + assert.ok(step, `Expected workflow step for template step ${templateStepId}`); + const approvals = await getJson(`${baseUrl}/approvals?cwd=${encodeURIComponent(cwd)}&status=pending&workflowStepId=${encodeURIComponent(step.id)}`); + approval = approvals.approvals[0]; + if (approval) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(approval, `Expected pending approval for workflow step ${templateStepId}`); + assert.equal(approval.workflowStepId, step.id); + assert.equal(approval.actionType, "workflow_step"); + assert.equal(approval.payload.type, "workflow_step"); + assert.equal(approval.payload.workflowStepId, step.id); + const actions = await getJson(`${baseUrl}/execution-actions?cwd=${encodeURIComponent(cwd)}&status=waiting_for_approval`); + assert.equal(actions.actions.some((action) => action.approvalId === approval.id && action.targetRefs.some((ref) => ref.type === "workflow_step" && ref.id === step.id)), true); + await postJson(`${baseUrl}/approvals/${encodeURIComponent(approval.id)}/approve?cwd=${encodeURIComponent(cwd)}`, { + reason: `approve ${templateStepId}`, + }); + return await postJson(`${baseUrl}/workflows/${encodeURIComponent(workflowId)}/orchestrate?cwd=${encodeURIComponent(cwd)}`, {}); +} + +async function postJson(url, body) { + const preparedBody = await prepareChannelCreateBody(url, body); + await ensureGitRepoForChannelPost(url, preparedBody); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(preparedBody), + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return projectCanonicalChannelConversationForTest(await response.json()); +} + +function projectCanonicalChannelConversationForTest(payload) { + if (!payload || typeof payload !== "object") return payload; + const decorations = Array.isArray(payload.decorations) ? payload.decorations : []; + const decorationByItemId = new Map(decorations.map((decoration) => [decoration.itemId, decoration])); + const projectItem = (item) => { + const decoration = decorationByItemId.get(item.id); + return { + ...projectConversationItemToChannelMessage(item, { + ...(decoration?.linkedRunId ? { linkedRunId: decoration.linkedRunId } : {}), + }), + ...(decoration?.editedAt ? { editedAt: decoration.editedAt } : {}), + ...(decoration?.pendingReplacement ? { pendingReplacement: decoration.pendingReplacement } : {}), + ...(decoration?.webEvidence ? { webEvidence: decoration.webEvidence } : {}), + }; + }; + if (payload.userItem) { + const message = projectItem(payload.userItem); + const aiReplies = (payload.assistantItems ?? []).map(projectItem); + return { + ...payload, + message, + aiReplies, + ...(aiReplies[0] ? { aiReply: aiReplies[0] } : {}), + }; + } + if (Array.isArray(payload.items)) { + return { + ...payload, + messages: payload.items + .filter((item) => item.kind === "user_message" || item.kind === "assistant_message" || item.kind === "system_event") + .map(projectItem), + }; + } + return payload; +} + +async function putJson(url, body) { + const response = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return await response.json(); +} + +async function patchJson(url, body) { + const response = await fetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return await response.json(); +} + +async function deleteJson(url) { + const response = await fetch(url, { + method: "DELETE", + }); + if (!response.ok) { + assert.fail(`${response.status} ${response.statusText}: ${await response.text()}`); + } + return response; +} + +export { + ASYNC_SETTLE_TIMEOUT_MS, + AbortableDelayedRunner, + DelayedCommandEvidenceRunner, + DelayedFileCommandRunner, + DelayedRunner, + DirectApprovalRunner, + DirectToolRunner, + MismatchedExactCommandRunner, + ParallelDelayedRunner, + PromptEchoRunner, + RecordingRunner, + ScriptedRunner, + SelectiveFailingRunner, + SummaryAwareRunner, + WorkflowEvidenceRunner, + approveWorkflowStepGate, + assertCoordinatorWorkflowStartReply, + assertIntentDecisionInspectPayloadIsSafe, + assertInvalidModelPlan, + assertSingleReplyIntentDecision, + createActiveChannelReplyRunFixture, + createAutoRouteTestChannel, + createChannel, + createParallelWorkflowStep, + createStaticChatProvider, + createStoreChannelAndMessage, + createWorkflowTestChannel, + customTemplate, + deleteJson, + escapeRegExp, + getArtifactJson, + getJson, + parseAcceptedPrivateSseEvents, + parseSseEvents, + patchJson, + postJson, + postTaskRunAndReadReadyRunId, + privateReplyTextEvents, + projectionFor, + publishHostOpenAvailability, + putJson, + readCurrentChannelUserMessage, + readDocxFixtureBytes, + readPdfFixtureBytes, + setWorkspaceDefaultProvider, + settleParallelDelayedWorkflow, + simulateLegacyChannelWithoutModel, + startDaemonForTest, + testDaemonConfig, + unusedRunnerRegistry, + waitForChannelMessageStatus, + waitForCondition, + waitForWorkflowStepStatus, + waitForWorkflowTemplateStepStatus, + withRestrictedCommandReadiness, +}; diff --git a/packages/daemon/test/test-support/recovery-discovery-fixture.mjs b/packages/daemon/test/test-support/recovery-discovery-fixture.mjs index 88f8280f3..cb52b60a3 100644 --- a/packages/daemon/test/test-support/recovery-discovery-fixture.mjs +++ b/packages/daemon/test/test-support/recovery-discovery-fixture.mjs @@ -21,6 +21,19 @@ import { RuntimeSupervisor } from "../../dist/runtime/supervisor.js"; export const DISCOVERY_CREATED_AT = "2026-10-01T00:00:00.000Z"; export const DISCOVERY_DUE_AT = "2026-10-01T01:00:00.000Z"; +function deferThreadMetadataRebuilds(store) { + const metadata = store.lingThreadMetadata; + const descriptor = Object.getOwnPropertyDescriptor(metadata, "rebuildLingThreadMetadata"); + const pending = new Map(); + // Only defer this derived read cache. Source writes and their transactions stay real. + metadata.rebuildLingThreadMetadata = (threadId, now) => { pending.set(threadId, now); }; + return () => { + if (descriptor) Object.defineProperty(metadata, "rebuildLingThreadMetadata", descriptor); + else delete metadata.rebuildLingThreadMetadata; + for (const [threadId, now] of pending) metadata.tryRebuildLingThreadMetadata(threadId, now); + }; +} + /** Real SQLite, frozen Pi input and Host policy. No provider request is made. */ export async function createRecoveryDiscoveryFixture(t, suffix, options = {}) { const cwd = await mkdtemp(join(tmpdir(), `ling-recovery-discovery-${suffix}-`)); @@ -129,18 +142,23 @@ export async function createRecoveryDiscoveryFixture(t, suffix, options = {}) { } function seedBlockedStalePrefix(seeded, count = 500) { - for (let index = 0; index < count; index += 1) { - const id = `z-blocked-${seeded.unit.kind}-${String(index).padStart(4, "0")}`; - const unit = store.createWorkUnit({ id: `unit-${id}`, workspaceId: "workspace_default", kind: seeded.unit.kind, - ownerType: seeded.unit.ownerType, ownerId: seeded.unit.ownerId, channelId: seeded.unit.channelId, - taskId: seeded.unit.taskId, teammateId: seeded.unit.teammateId, runnerId: "pi", effectiveRunnerId: "pi", - providerPresetId: preset.id, idempotencyKey: id, status: "stale", request: {}, createdByActor: "system", - now: seeded.unit.createdAt }); - store.createWorkUnitAttempt({ id: `attempt-${id}`, workUnitId: unit.id, attemptNumber: 1, - runtimeInstanceId: "runtime-before-restart", runnerId: "pi", status: "lost", now: seeded.unit.createdAt }); - const run = store.createRun({ ...seeded.run, id: `run-${id}`, workUnitId: unit.id, createdAt: seeded.unit.createdAt }); - store.linkRunToWorkUnit(run.id, unit.id, seeded.unit.createdAt); - store.markRunCancelled(run.id, seeded.unit.createdAt); + const flushMetadata = deferThreadMetadataRebuilds(store); + try { + for (let index = 0; index < count; index += 1) { + const id = `z-blocked-${seeded.unit.kind}-${String(index).padStart(4, "0")}`; + const unit = store.createWorkUnit({ id: `unit-${id}`, workspaceId: "workspace_default", kind: seeded.unit.kind, + ownerType: seeded.unit.ownerType, ownerId: seeded.unit.ownerId, channelId: seeded.unit.channelId, + taskId: seeded.unit.taskId, teammateId: seeded.unit.teammateId, runnerId: "pi", effectiveRunnerId: "pi", + providerPresetId: preset.id, idempotencyKey: id, status: "stale", request: {}, createdByActor: "system", + now: seeded.unit.createdAt }); + store.createWorkUnitAttempt({ id: `attempt-${id}`, workUnitId: unit.id, attemptNumber: 1, + runtimeInstanceId: "runtime-before-restart", runnerId: "pi", status: "lost", now: seeded.unit.createdAt }); + const run = store.createRun({ ...seeded.run, id: `run-${id}`, workUnitId: unit.id, createdAt: seeded.unit.createdAt }); + store.linkRunToWorkUnit(run.id, unit.id, seeded.unit.createdAt); + store.markRunCancelled(run.id, seeded.unit.createdAt); + } + } finally { + flushMetadata(); } } @@ -198,9 +216,14 @@ export async function waitForRecovery(predicate, message, timeoutMs = 5_000) { } export async function seedScheduledBlockedPrefix(fixture) { - for (let index = 0; index < 500; index += 1) { - const seeded = await fixture.seedRun("conversation", `scheduled-blocked-${String(index).padStart(4, "0")}`); - fixture.schedule(seeded, DISCOVERY_DUE_AT, `a-blocked-${String(index).padStart(4, "0")}`); - fixture.store.markRunCancelled(seeded.run.id, seeded.run.createdAt); + const flushMetadata = deferThreadMetadataRebuilds(fixture.store); + try { + for (let index = 0; index < 500; index += 1) { + const seeded = await fixture.seedRun("conversation", `scheduled-blocked-${String(index).padStart(4, "0")}`); + fixture.schedule(seeded, DISCOVERY_DUE_AT, `a-blocked-${String(index).padStart(4, "0")}`); + fixture.store.markRunCancelled(seeded.run.id, seeded.run.createdAt); + } + } finally { + flushMetadata(); } } diff --git a/patches/README.md b/patches/README.md index a499492da..210f96ad9 100644 --- a/patches/README.md +++ b/patches/README.md @@ -8,26 +8,64 @@ Reviewed baseline: `fd39ec302cbae759e4d4a866172a804e1bc5abfd`, 2026-10-12. | Package | Advisory / purpose | Actual consumer | Removal gate | | --- | --- | --- | --- | -| `@expo/cli@58.1.13` | Preserve the optional MCP tunnel's Zod 3 schema protocol while the CLI uses fixed Zod 4 for [GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) | Expo dev-tools MCP command schemas | Upstream tunnel supports Zod 4 and command enum, parameter types, and strict JSON Schema conversion regressions pass without this bridge | -| `node-forge@1.4.0` | [GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv): reject unconsumed nested DigestAlgorithm elements | Expo code-signing certificates | Released upstream fix plus malformed-signature and valid-certificate regressions | +| `@expo/cli@58.1.13` (historical bridge, no longer enabled) | Preserve the optional MCP tunnel's Zod 3 schema protocol while the old CLI used fixed Zod 4 for [GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5) | Expo dev-tools MCP command schemas | Met with CLI 58.1.18: upstream injects expo-mcp's own Zod into the command factory; actual entry, tunnel serialization, strict schemas and executor regressions pass without the bridge | +| `node-forge@1.4.2` (upstream fix; local patch removed) | [GHSA-86w9-cpqp-85rv](https://github.com/advisories/GHSA-86w9-cpqp-85rv): reject unconsumed nested DigestAlgorithm elements | Expo code-signing certificates | Met on 2026-11-02: upstream canonical DER validation plus malformed-signature and valid-certificate regressions | | `braces@3.0.3` | [GHSA-vfj7-8cjw-p6xm](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm): cap brace/parenthesis nesting and validate direct AST inputs | Metro file matching via micromatch | Upstream depth protection covering parse, compile, expand and stringify | | `sprintf-js@1.0.3`, `sprintf-js@1.1.3` | [GHSA-hp3w-g68c-fv3c](https://github.com/advisories/GHSA-hp3w-g68c-fv3c): bound floating-point precision | Mammoth CLI via argparse; Desktop downloader logger via Roarr | Upstream protection plus formatter, CLI help and logger regressions | | `http-cache-semantics@4.3.0` | [GHSA-ch52-4w7c-c8xp](https://github.com/advisories/GHSA-ch52-4w7c-c8xp): never revive zero-freshness entries via max-stale; retain upstream Vary fixes | Desktop Electron downloader via got/cacheable-request | Released fix passing all private, cookie, no-cache, no-store, Vary and public-cache tests | -| `metro@0.87.0` (historical patch, not enabled in SDK57's graph) | Adapt image-size v2 named exports and asynchronous file API | Only a future actual Metro 0.87 consumer; current RN/Expo Metro 0.84.5 uses an internal parser | Re-enable only for an actual matching consumer and verify both asset paths | +| `metro@0.87.0` (historical patch, not enabled) | Adapt image-size v2 named exports and asynchronous file API | Current SDK58 resolves RN Metro 0.87.2 and Expo Metro 0.87.1; both use an internal parser | Re-enable only for an actual matching version whose asset implementation needs this adaptation, and verify both asset paths | | `image-size@2.0.4` | Retain strict ICNS header/entry bounds from the old local patch in both CJS and ESM | PPTX dependency tree | Upstream equivalent boundary validation passing image regressions | +| `minimatch@9.0.9`, `minimatch@10.2.6` | [GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249): bound globstar-parent preprocessing and quadratic deduplication | Pi SDK, Electron builder/universal and Expo CLI | Compatible upstream release containing both limits, passing both module-format and consumer regressions | +| `glob@11.1.0`, `glob@13.0.6` | [GHSA-3g3m-2fjr-6pw4](https://github.com/advisories/GHSA-3g3m-2fjr-6pw4): stop directory traversal after abort | Expo MCP, Electron/Expo packaging tools | Compatible upstream release containing all walker cancellation/resume fixes, passing abort, stream and ASAR regressions | ## Patch Behavior And Provenance -- `@expo/cli`: only the MCP command-schema factory imports the fixed Zod - package's public `zod/v3` compatibility entry. Its command enum and optional - string, number, and boolean parameters retain the schema protocol consumed by - `@expo/mcp-tunnel` and `zod-to-json-schema` 3. The plugin and output collection - validators keep the patched Zod 4 default export. This local compatibility - bridge contains no array, map, or set schemas and does not backport Zod 4's - collection fix to the compatibility entry. -- `node-forge`: backport the `lib/rsa.js` change from +- `minimatch`: backport the two preprocessing limits from official commit + [`77a0f5c4`](https://github.com/isaacs/minimatch/commit/77a0f5c4b80bdc641c3c930a1d9edf5ad2db57f8) + to both CJS and ESM. Version 10 shares its existing `braceExpandMax` (default + 100,000) with globstar-parent splitting. Version 9 has no such public option, + so it uses the same fixed default budget without inventing a new option or + changing its brace-expansion API. Both skip optional pairwise deduplication + above 1,000 patterns while preserving matching. The regression uses a + near-budget pattern set and a small explicit limit, not an unbounded input + that could hang a CI worker. Legacy versions 3 and 5 have no level-two + preprocessing implementation; this backport does not upgrade them or claim + that version-based advisories have disappeared. +- `glob`: backport official commit + [`4682e8c9`](https://github.com/isaacs/node-glob/commit/4682e8c9babee1b385202ede36389634e12076d7) + without crossing consumer-declared majors. All four recursive walker entry + points return immediately after completing an aborted callback; resume + callbacks must still settle already-paused work. Version 13's default exports, + `main` and `module` select its existing unbundled entrypoints (also published as + `glob/raw`), retaining the same public API and types. This ensures real + consumers load both the patched walker and the separately patched minimatch, + instead of unchanged copies embedded in `index.min.js`. No dependency majors + or dependency declarations change. Both default and raw entries, in both + module formats, have cancellation and pattern-budget regressions. Legacy + glob 7 is outside the cancellation advisory and remains unchanged. +- `@expo/cli`: Expo 58.0.21 resolves CLI 58.1.18, which no longer depends on + Zod for plugin or output collection validation. Both actual consumers use + `@expo/schema-utils`; its bounded-array regression checks rejection before + visiting elements. The real `MCP.js` entry resolves Zod and the tunnel relative + to `expo-mcp`, then injects that Zod into the CLI command-schema factory. + `expo-mcp@0.2.4` and `@expo/mcp-tunnel@0.2.4` share Zod 3.25.76, preserving the + command enum, optional string/number/boolean parameters and strict JSON Schema + conversion. The tunnel still uses Zod 3, not Zod 4. The old bridge is inactive + because this upstream injection replaces its purpose, not because the tunnel + gained Zod 4 support. Its historical patch file remains for provenance. + The mobile devDependency on `expo-mcp` makes the optional integration's actual + entry and tunnel part of the security regression gate without enabling MCP or + opening a connection; tests also execute a local plugin and reject invalid + calls before spawning it. Fixed Zod's original bounded-array regression is + retained through the daemon MCP consumer, separately from the new Expo path. +- `node-forge`: the former local patch backported the `lib/rsa.js` change from [official PR 1152](https://github.com/digitalbazaar/forge/pull/1152), reviewed at - `ceba34402e329f0365134f23fe19898756527d65`. No cryptographic primitive is replaced. + `ceba34402e329f0365134f23fe19898756527d65`. Published 1.4.2 now re-encodes + DigestInfo as canonical DER and checks the encoded bytes, rejecting the extra + nested elements guarded by that patch. The obsolete patch was removed only + after a real frozen install and all 47 dependency-security regressions passed, + including malformed signatures, valid optional NULL parameters and Expo + certificate/signature round-trips through the actual consumers. - `braces`: the fixed maximum is 100 nested containers. Parsing rejects excessive brace and parenthesis depth before recursive work. An iterative preflight also protects caller-supplied deep or cyclic ASTs. Escaped and quoted literals do not @@ -49,9 +87,11 @@ Reviewed baseline: `fd39ec302cbae759e4d4a866172a804e1bc5abfd`, 2026-10-12. [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). The historical Metro 0.87 patch uses `imageSize` for buffers and awaits - `imageSizeFromFile` for files. SDK57's actual Metro 0.84.5 does not consume - image-size, so its two asset paths are tested independently; malformed-image - proofs resolve image-size through the actual PPTX consumer. The obsolete + `imageSizeFromFile` for files. SDK57 with Metro 0.84.5 was the historical + baseline, not the current dependency graph. SDK58 currently resolves RN Metro + 0.87.2 and Expo Metro 0.87.1; both use their internal image parser. Their buffer + and file asset paths have independent regressions; malformed-image proofs + resolve image-size through the actual PPTX consumer. The obsolete `1.2.1` patch is removed only after the parser, asset and PPT generation checks pass. @@ -67,8 +107,9 @@ Also build Web and Desktop when changing dependency resolution. Successful source builds are not signed macOS/Windows installer acceptance. Local patches do not change upstream package versions, so Dependabot can continue -to flag `node-forge`, `braces`, `sprintf-js` and the cache package. Keep those -alerts auditable; do not silently dismiss them or claim remote closure before +to flag `braces`, `sprintf-js` and the cache package. Keep those +alerts auditable; this also applies to the `minimatch` and `glob` backports. Do +not silently dismiss them or claim remote closure before the changes are merged and GitHub rescans the dependency graph. ### Local Validation Snapshot (2026-10-12) diff --git a/patches/glob@11.1.0.patch b/patches/glob@11.1.0.patch new file mode 100644 index 000000000..13372539a --- /dev/null +++ b/patches/glob@11.1.0.patch @@ -0,0 +1,96 @@ +diff --git a/dist/commonjs/walker.js b/dist/commonjs/walker.js +--- a/dist/commonjs/walker.js ++++ b/dist/commonjs/walker.js +@@ -77,8 +77,6 @@ + } + } + onResume(fn) { +- if (this.signal?.aborted) +- return; + /* c8 ignore start */ + if (!this.paused) { + fn(); +@@ -185,7 +183,7 @@ + walkCB(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2(target, patterns, new processor_js_1.Processor(this.opts), cb); + } +@@ -193,7 +191,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2(target, patterns, processor, cb)); + return; +@@ -249,7 +247,7 @@ + walkCBSync(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2Sync(target, patterns, new processor_js_1.Processor(this.opts), cb); + } +@@ -257,7 +255,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2Sync(target, patterns, processor, cb)); + return; +diff --git a/dist/esm/walker.js b/dist/esm/walker.js +--- a/dist/esm/walker.js ++++ b/dist/esm/walker.js +@@ -74,8 +74,6 @@ + } + } + onResume(fn) { +- if (this.signal?.aborted) +- return; + /* c8 ignore start */ + if (!this.paused) { + fn(); +@@ -182,7 +180,7 @@ + walkCB(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2(target, patterns, new Processor(this.opts), cb); + } +@@ -190,7 +188,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2(target, patterns, processor, cb)); + return; +@@ -246,7 +244,7 @@ + walkCBSync(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2Sync(target, patterns, new Processor(this.opts), cb); + } +@@ -254,7 +252,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2Sync(target, patterns, processor, cb)); + return; diff --git a/patches/glob@13.0.6.patch b/patches/glob@13.0.6.patch new file mode 100644 index 000000000..18a52e0d5 --- /dev/null +++ b/patches/glob@13.0.6.patch @@ -0,0 +1,131 @@ +diff --git a/dist/commonjs/walker.js b/dist/commonjs/walker.js +--- a/dist/commonjs/walker.js ++++ b/dist/commonjs/walker.js +@@ -77,8 +77,6 @@ + } + } + onResume(fn) { +- if (this.signal?.aborted) +- return; + /* c8 ignore start */ + if (!this.paused) { + fn(); +@@ -185,7 +183,7 @@ + walkCB(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2(target, patterns, new processor_js_1.Processor(this.opts), cb); + } +@@ -193,7 +191,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2(target, patterns, processor, cb)); + return; +@@ -249,7 +247,7 @@ + walkCBSync(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2Sync(target, patterns, new processor_js_1.Processor(this.opts), cb); + } +@@ -257,7 +255,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2Sync(target, patterns, processor, cb)); + return; +diff --git a/dist/esm/walker.js b/dist/esm/walker.js +--- a/dist/esm/walker.js ++++ b/dist/esm/walker.js +@@ -74,8 +74,6 @@ + } + } + onResume(fn) { +- if (this.signal?.aborted) +- return; + /* c8 ignore start */ + if (!this.paused) { + fn(); +@@ -182,7 +180,7 @@ + walkCB(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2(target, patterns, new Processor(this.opts), cb); + } +@@ -190,7 +188,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2(target, patterns, processor, cb)); + return; +@@ -246,7 +244,7 @@ + walkCBSync(target, patterns, cb) { + /* c8 ignore start */ + if (this.signal?.aborted) +- cb(); ++ return cb(); + /* c8 ignore stop */ + this.walkCB2Sync(target, patterns, new Processor(this.opts), cb); + } +@@ -254,7 +252,7 @@ + if (this.#childrenIgnored(target)) + return cb(); + if (this.signal?.aborted) +- cb(); ++ return cb(); + if (this.paused) { + this.onResume(() => this.walkCB2Sync(target, patterns, processor, cb)); + return; +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -14,14 +14,14 @@ + "import": { + "types": "./dist/esm/index.d.ts", +- "default": "./dist/esm/index.min.js" ++ "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", +- "default": "./dist/commonjs/index.min.js" ++ "default": "./dist/commonjs/index.js" + } + } + } + }, +- "main": "./dist/commonjs/index.min.js", +- "module": "./dist/esm/index.min.js", ++ "main": "./dist/commonjs/index.js", ++ "module": "./dist/esm/index.js", + "types": "./dist/commonjs/index.d.ts", +@@ -41,10 +41,10 @@ + ".": { + "import": { + "types": "./dist/esm/index.d.ts", +- "default": "./dist/esm/index.min.js" ++ "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", +- "default": "./dist/commonjs/index.min.js" ++ "default": "./dist/commonjs/index.js" + } + } diff --git a/patches/minimatch@10.2.6.patch b/patches/minimatch@10.2.6.patch new file mode 100644 index 000000000..240c4747b --- /dev/null +++ b/patches/minimatch@10.2.6.patch @@ -0,0 +1,80 @@ +diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js +--- a/dist/commonjs/index.js ++++ b/dist/commonjs/index.js +@@ -455,4 +455,6 @@ + firstPhasePreProcess(globParts) { ++ // Each **/.. split doubles patterns, so share the brace expansion budget. ++ const { braceExpandMax = 100_000 } = this.options; + let didSomething = false; + do { + didSomething = false; +@@ -480,7 +482,8 @@ + p === '..' || + !p2 || + p2 === '.' || +- p2 === '..') { ++ p2 === '..' || ++ globParts.length >= braceExpandMax) { + continue; + } + didSomething = true; +@@ -539,4 +542,7 @@ + secondPhasePreProcess(globParts) { ++ // Pairwise deduplication is optional and quadratic in the pattern count. ++ if (globParts.length > 1000) ++ return globParts; + for (let i = 0; i < globParts.length - 1; i++) { + for (let j = i + 1; j < globParts.length; j++) { + const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes); +diff --git a/dist/esm/index.js b/dist/esm/index.js +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -455,4 +455,6 @@ + firstPhasePreProcess(globParts) { ++ // Each **/.. split doubles patterns, so share the brace expansion budget. ++ const { braceExpandMax = 100_000 } = this.options; + let didSomething = false; + do { + didSomething = false; +@@ -480,7 +482,8 @@ + p === '..' || + !p2 || + p2 === '.' || +- p2 === '..') { ++ p2 === '..' || ++ globParts.length >= braceExpandMax) { + continue; + } + didSomething = true; +@@ -539,4 +542,7 @@ + secondPhasePreProcess(globParts) { ++ // Pairwise deduplication is optional and quadratic in the pattern count. ++ if (globParts.length > 1000) ++ return globParts; + for (let i = 0; i < globParts.length - 1; i++) { + for (let j = i + 1; j < globParts.length; j++) { + const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes); +diff --git a/dist/commonjs/index.d.ts b/dist/commonjs/index.d.ts +--- a/dist/commonjs/index.d.ts ++++ b/dist/commonjs/index.d.ts +@@ -69,6 +69,8 @@ + windowsNoMagicRoot?: boolean; + /** + * max number of `{...}` patterns to expand. Default 100_000. ++ * At optimizationLevel >= 2, also limits patterns created by globstar ++ * followed by a parent-directory segment. + */ + braceExpandMax?: number; + /** +diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts +--- a/dist/esm/index.d.ts ++++ b/dist/esm/index.d.ts +@@ -69,6 +69,8 @@ + windowsNoMagicRoot?: boolean; + /** + * max number of `{...}` patterns to expand. Default 100_000. ++ * At optimizationLevel >= 2, also limits patterns created by globstar ++ * followed by a parent-directory segment. + */ + braceExpandMax?: number; + /** diff --git a/patches/minimatch@9.0.9.patch b/patches/minimatch@9.0.9.patch new file mode 100644 index 000000000..719889bf7 --- /dev/null +++ b/patches/minimatch@9.0.9.patch @@ -0,0 +1,56 @@ +diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js +--- a/dist/commonjs/index.js ++++ b/dist/commonjs/index.js +@@ -448,4 +448,6 @@ + firstPhasePreProcess(globParts) { ++ // v9 has no braceExpandMax option; retain upstream's default split budget. ++ const braceExpandMax = 100_000; + let didSomething = false; + do { + didSomething = false; +@@ -473,7 +475,8 @@ + p === '..' || + !p2 || + p2 === '.' || +- p2 === '..') { ++ p2 === '..' || ++ globParts.length >= braceExpandMax) { + continue; + } + didSomething = true; +@@ -532,4 +535,7 @@ + secondPhasePreProcess(globParts) { ++ // Pairwise deduplication is optional and quadratic in the pattern count. ++ if (globParts.length > 1000) ++ return globParts; + for (let i = 0; i < globParts.length - 1; i++) { + for (let j = i + 1; j < globParts.length; j++) { + const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes); +diff --git a/dist/esm/index.js b/dist/esm/index.js +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -448,4 +448,6 @@ + firstPhasePreProcess(globParts) { ++ // v9 has no braceExpandMax option; retain upstream's default split budget. ++ const braceExpandMax = 100_000; + let didSomething = false; + do { + didSomething = false; +@@ -473,7 +475,8 @@ + p === '..' || + !p2 || + p2 === '.' || +- p2 === '..') { ++ p2 === '..' || ++ globParts.length >= braceExpandMax) { + continue; + } + didSomething = true; +@@ -532,4 +535,7 @@ + secondPhasePreProcess(globParts) { ++ // Pairwise deduplication is optional and quadratic in the pattern count. ++ if (globParts.length > 1000) ++ return globParts; + for (let i = 0; i < globParts.length - 1; i++) { + for (let j = i + 1; j < globParts.length; j++) { + const matched = this.partsMatch(globParts[i], globParts[j], !this.preserveMultipleSlashes); diff --git a/patches/node-forge@1.4.0.patch b/patches/node-forge@1.4.0.patch deleted file mode 100644 index 278f099a5..000000000 --- a/patches/node-forge@1.4.0.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/lib/rsa.js b/lib/rsa.js ---- a/lib/rsa.js -+++ b/lib/rsa.js -@@ -1168,11 +1168,16 @@ pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) { - parseAllBytes: options._parseAllDigestBytes - }); - -- // validate DigestInfo structure and element count -+ // validate DigestInfo structure and element counts (outer DigestInfo -+ // and nested DigestAlgorithm). asn1.validate ignores extra children, -+ // so length must be checked explicitly at each nesting level to -+ // prevent low-exponent PKCS#1 v1.5 signature forgery (CVE-2026-85393). - var capture = {}; - var errors = []; - if(!asn1.validate(obj, digestInfoValidator, capture, errors) || -- obj.value.length !== 2) { -+ obj.value.length !== 2 || -+ obj.value[0].value.length !== -+ (('parameters' in capture) ? 2 : 1)) { - var error = new Error( - 'ASN.1 object does not contain a valid RSASSA-PKCS1-v1_5 ' + - 'DigestInfo value.'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000ff2972..7b7ca28fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,6 @@ settings: overrides: '@radix-ui/react-dismissable-layer@1.1.19': 1.1.20 - '@expo/cli>zod': 4.6.6 brace-expansion@>=1.0.0 <1.1.21: 1.1.21 brace-expansion@>=2.0.0 <2.1.7: 2.1.7 brace-expansion@>=5.0.0 <5.0.12: 5.0.12 @@ -30,9 +29,6 @@ overrides: exceljs>unzipper: 0.11.2 patchedDependencies: - '@expo/cli@58.1.13': - hash: 12f30ea7444cd7bac70249452357c3b0fc1bad94ed2841996fb82c508a7b139d - path: patches/@expo__cli@58.1.13.patch braces@3.0.3: hash: ad74a34b0200bbe14ba95e31e8f296205ce32a374eca2cef25cc8940aa0c76c1 path: patches/braces@3.0.3.patch @@ -42,15 +38,24 @@ patchedDependencies: image-size@2.0.4: hash: 4045ad5d2de4ed47956f4f703fa65e6328c367e3e1acff969728cbb50b29187b path: patches/image-size@2.0.4.patch - node-forge@1.4.0: - hash: 50d8e3f38443736858c2c81dc2b569c18083e687f81acc5f9c17a568a7e4f6f6 - path: patches/node-forge@1.4.0.patch sprintf-js@1.0.3: hash: bd6495e712b47ec612cde7288449c45ab0c719717b423e6a94b693e297bbb452 path: patches/sprintf-js@1.0.3.patch sprintf-js@1.1.3: hash: 82b04dd9d1d8254e00580a51ea22a3710721fd7fa0350e51ef33829396255efb path: patches/sprintf-js@1.1.3.patch + minimatch@9.0.9: + hash: d82eb6ab78ab1012d55ae44d746c1f8f19d358535ebc56e5e865c5daa78cb1f4 + path: patches/minimatch@9.0.9.patch + minimatch@10.2.6: + hash: 4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7 + path: patches/minimatch@10.2.6.patch + glob@11.1.0: + hash: 181caa5dc9cc2da79ef843bd0d516d35d1b2049a1414d5ebb5dc1c7c6245febd + path: patches/glob@11.1.0.patch + glob@13.0.6: + hash: 6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7 + path: patches/glob@13.0.6.patch importers: @@ -157,10 +162,10 @@ importers: dependencies: '@expo/dom-webview': specifier: ~58.0.2 - version: 58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@expo/vector-icons': specifier: ^15.0.2 - version: 15.1.1(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 15.1.1(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@ling-stack/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -175,50 +180,53 @@ importers: version: link:../../packages/remote-protocol '@react-native-async-storage/async-storage': specifier: 2.2.0 - version: 2.2.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + version: 2.2.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) '@react-navigation/native': specifier: ^7.5.1 - version: 7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@react-navigation/native-stack': specifier: ^7.21.0 - version: 7.21.0(@react-navigation/native@7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-screens@4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 7.21.0(@react-navigation/native@7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-screens@4.30.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) expo: - specifier: ~58.0.16 - version: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + specifier: 58.0.21 + version: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) expo-camera: - specifier: ~58.0.11 - version: 58.0.12(@types/emscripten@1.41.5)(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - expo-dev-client: specifier: ~58.0.14 - version: 58.0.14(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + version: 58.0.14(@types/emscripten@1.41.5)(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + expo-dev-client: + specifier: ~58.0.17 + version: 58.0.17(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) expo-font: - specifier: ~58.0.9 - version: 58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + specifier: ~58.0.11 + version: 58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) expo-image-picker: specifier: ~58.0.10 - version: 58.0.10(expo@58.0.16) + version: 58.0.10(expo@58.0.21) expo-network: - specifier: ~58.0.3 - version: 58.0.3(expo@58.0.16)(react@19.3.0) + specifier: ~58.0.4 + version: 58.0.4(expo@58.0.21)(react@19.3.0) expo-secure-store: specifier: ~58.0.2 - version: 58.0.2(expo@58.0.16) + version: 58.0.2(expo@58.0.21) react: specifier: 19.3.0 version: 19.3.0 react-native: - specifier: 0.88.0 - version: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + specifier: 0.88.1 + version: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) react-native-safe-area-context: specifier: ~5.10.1 - version: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) react-native-screens: specifier: ~4.30.0 - version: 4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + version: 4.30.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) devDependencies: expo-doctor: specifier: 1.21.4 version: 1.21.4 + expo-mcp: + specifier: 0.2.4 + version: 0.2.4 react-dom: specifier: 19.3.0 version: 19.3.0(react@19.3.0) @@ -738,6 +746,10 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.12': + resolution: {integrity: sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} @@ -809,6 +821,10 @@ packages: resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.11': + resolution: {integrity: sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -837,6 +853,11 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.12': + resolution: {integrity: sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} @@ -916,8 +937,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-block-scoping@7.29.7': - resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + '@babel/plugin-transform-block-scoping@7.29.12': + resolution: {integrity: sha512-nEreZBofjfd6FG621xe9Rk3+tMGBXf1nAuFrR6NUGF67fkQbSQf3NrbRuhgWRn+zb0qcy9XVok1GVFI/t9wWVQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -934,8 +955,8 @@ packages: peerDependencies: '@babel/core': ^7.12.0 - '@babel/plugin-transform-classes@7.29.7': - resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + '@babel/plugin-transform-classes@7.29.11': + resolution: {integrity: sha512-f8kTDM/nTvpLeafb+TJ4jboFr/Y3BbC+QzgPpPGzSKJqWLnj9LW0WLqv4XKA11LX5sr+EgvLE6Ix3YsCEusodw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -1080,10 +1101,18 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.12': + resolution: {integrity: sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} @@ -1345,14 +1374,17 @@ packages: cpu: [x64] os: [win32] - '@expo/cli@58.1.13': - resolution: {integrity: sha512-a3FBo818/laj78tmDe8j/oOmxv8XBtA7nYdQQNHjebj1+RbhJjlZNxc2e1SwFXj8pXtruCdrs7fNFQTAFjS6GA==} + '@expo/cli@58.1.18': + resolution: {integrity: sha512-voZUcir9mEwvLKBNqQM6Jy2Yp67z8ONmcINXb6ZrLMayb7yxzwvEnN0eeH8ADzUABKL288pv1eJ1WRqtYxXQ/Q==} hasBin: true peerDependencies: expo: '*' + expo-mcp: '*' expo-router: '*' react-native: '*' peerDependenciesMeta: + expo-mcp: + optional: true expo-router: optional: true react-native: @@ -1362,14 +1394,14 @@ packages: resolution: {integrity: sha512-zP0tnfqrFFRL8INtOaQ6XwEYfdIpIH9vrEo7y0bHQGQa50z4/QWXE5CI4nRUHxqFW4MVWOwXFg1hom1aAZHNkQ==} engines: {node: '>=22.13.0'} - '@expo/config-plugins@58.0.9': - resolution: {integrity: sha512-HyP7V1QgDxeHcOEh4qQH7cfK7cI7wNp0y9lszAu98SRYCshhDbwB9gWpFfnpX1GQUtgiqG4Hvgh1z3ntcGwAjw==} + '@expo/config-plugins@58.0.11': + resolution: {integrity: sha512-lenBm4ajWMPW+Hol3xiHHoSZEO/mA3T7X/NU3XNqm96XepwjXh2CMpIvROGLoh0amfVEIXIW3Qj3gnDS3cK/nw==} '@expo/config-types@58.0.3': resolution: {integrity: sha512-wyfrwkTT/hQHzwyqkXkngKxoOa8L6ovsTP4h/UukS1mTmssee5et6HOPoN5RtawdJPnm5JO28f//C/wXlpka8Q==} - '@expo/config@58.0.6': - resolution: {integrity: sha512-PT1qv4s/R5A/NxXb4efGlFsYF5Consi7F8xSsJBAN090EGDr6U1EDQGOijFqzDc3JA2epD8zjKLJLOVB9YOewg==} + '@expo/config@58.0.10': + resolution: {integrity: sha512-EC2BKXf9nIVrsvpwMOIUw8fyJPLU4moeFFiWhQivpVX9Ta9EUfXjpeoJHhv8Nt7+lx9RfRfZy9sgMsSUVMuRBw==} '@expo/devcert@1.2.2': resolution: {integrity: sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==} @@ -1395,31 +1427,31 @@ packages: react: '*' react-native: '*' - '@expo/env@2.5.2': - resolution: {integrity: sha512-z8kCvBVJpd2S20a+Ab1otQgz8sJLeR7CXDTsuAMcNbYFsK10Fdhfb7rx9Ys1vvlbijnY55eZhQvJACajc1IjBQ==} + '@expo/env@2.5.3': + resolution: {integrity: sha512-dBGu8BAfvUcXvG4HvYth+EhcHEsdC+/fT6HTH/zFvvwfp50LG6uzpzT8TXObwEq/t2B/rvAMV5OolxQ46LWXHg==} engines: {node: '>=20.12.0'} - '@expo/fingerprint@0.21.5': - resolution: {integrity: sha512-4ngc+aPgMK56sYc47tfabmoaNdP7OvPBOH7QPgN3riSYM0Ev+KwsGRDius5jBBwjCI9ADp7IpjJEK9xlRYjjvA==} + '@expo/fingerprint@0.21.8': + resolution: {integrity: sha512-ufFLmY4Lyf8POxZgaWsZGq3p16fWJUCrkkm9E0HM9mw74vGQkRu5sKUIPde+ROGXv2cWLsrudJkUZLU5l6Clvw==} hasBin: true - '@expo/image-utils@0.12.5': - resolution: {integrity: sha512-nsFYd7GfDdwZa+S+p+EkVCkh7UKKNf0kfUXDQ53HbyMmNFD3psb5JYwU/2N+uZG5wOEfKGfQ5u7pk/wn2gMlBA==} + '@expo/image-utils@0.12.6': + resolution: {integrity: sha512-AWzD/c3mM/a+sDbRgOSNzBpkUW8sUVUFcwnZ7xqkkhc5Hi9Fms0pDGw24JLxccgA6cC6bbJ8sZiQbnXBf75onA==} - '@expo/inline-modules@0.2.6': - resolution: {integrity: sha512-S8PK3wx6ogwunoQ7pqdPbklQGtXloeWTAOzWJ9yIL7xHFxvnI/Ryj0phA3KEGCMlaKZjaCP1h/3s7n/ueErRgQ==} + '@expo/inline-modules@0.2.8': + resolution: {integrity: sha512-7x+mbe6/rWCD6iWSPl8s7Jipdr+Ss9f6raFdHSl8oIouSL09pszJb7bD15oLje6AkioFVe/lgq/QOqKB7w8bWw==} '@expo/json-file@11.2.1': resolution: {integrity: sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==} - '@expo/local-build-cache-provider@58.0.6': - resolution: {integrity: sha512-AJk3pslFNlwRMbUbfvxhu6CPA9U7A+JPb88VS/uWkG/S/QMJL90Iw4sPVfG4BOKfRUNkB8ipl432Dr4txjSMKw==} + '@expo/local-build-cache-provider@58.0.10': + resolution: {integrity: sha512-0O0EF9V2rEgIPYJI+4vYLEKQn3xUt2js4L2L6DLE9PrElozL7cvHJKPkfEpYSxo5NNJhyGfVxBdazd0vE4Xg3w==} '@expo/log-box-utils@58.0.2': resolution: {integrity: sha512-My8Lwe9FU/yDwwzqwi5LN2rzzDcJqIAZKrD9f0XHZEKz/FQnWJYdt1rsV7+RwqMGeGq2XsxTCVT1ggTCOvumxw==} - '@expo/log-box@58.0.12': - resolution: {integrity: sha512-IzDYoFY2iUsmSDvl1V7YUM7UkCPDyMVS4e4a38c2LnFc1A3WGMUiQh+vsHLYTPKjIelu4r7/u4V7Gxtfa0oJgA==} + '@expo/log-box@58.0.14': + resolution: {integrity: sha512-0aXQIWgTBvtzlzzqS7+oDx+DeGyX961Xxr53HzWWiyxNM9PkK7QXVV2gVeuvAYL0RIKUL1hNC0ZCz7sSkYyfww==} peerDependencies: '@expo/dom-webview': ^58.0.2 expo: '*' @@ -1429,8 +1461,13 @@ packages: expo: optional: true - '@expo/metro-config@58.0.15': - resolution: {integrity: sha512-uVfYUVEknMo+T0xpTO+0l/rnaQUqBBwE5ALnQ/82cVIelKfDvW53ygjAC/AUFreLxhtmsGDBXWusegn16AI/RQ==} + '@expo/mcp-tunnel@0.2.4': + resolution: {integrity: sha512-hxFzqdUNKCt+8pbGV3oGcd/aBNA1mmhwh3DSeXoHReypxzsiLYLITJs1OctglaPecfMA9qFb+6z/RIkRSf5S4g==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.26.0 + + '@expo/metro-config@58.0.20': + resolution: {integrity: sha512-M8O9hcpHTYXZjuUO0UzHInbpqKiWVXW/If8ZD3tdg1l01vDjoS6ol5lzP0IJhGY4VeJNJI3U5MsdS7dxx0AlLw==} peerDependencies: expo: '*' peerDependenciesMeta: @@ -1443,23 +1480,23 @@ packages: '@expo/metro@58.0.0': resolution: {integrity: sha512-PMD640ihB0C3Oqm0AnDVJ/8qF2cKbRUEqGuSOr4tM/OTb9ktxsQqsiSSSeaOEgdCZBq20AxRS0d6WHNhhH2P+g==} - '@expo/modules-macros-linux-arm64@0.21.0': - resolution: {integrity: sha512-et6K38xG21MBiYGrSfLlh3pD8ZuWkUrg7O1fdQxuLUvmFggb/C1ddo29SHLguJ+5Hjl+6J+ncEgpIWt6pQZjIw==} + '@expo/modules-macros-linux-arm64@0.27.1': + resolution: {integrity: sha512-8bwGkFZ5FaLzSXe37t8ksw4GJ7flvRhBSmmFfkZlETjm8nAPrU5D+VF8YkSmRUoQ5DA3PWLLwnYK+WhvzJ0+fA==} cpu: [arm64] os: [linux] - '@expo/modules-macros-linux-x64@0.21.0': - resolution: {integrity: sha512-p/vMS+FrWTvk+um3+8fd7/3VBJQxnPu8SjXGu4zs8lYPWaVOfpyjE80ziDW/KEOUyueT/U9DEzxX8JE/uDDdUg==} + '@expo/modules-macros-linux-x64@0.27.1': + resolution: {integrity: sha512-63BZcm/aWPNe4VC9jBxKOME7abiM6rCdn6NiZTTmlEMWmBaoRWJEKj16tT/Gjkf25y9+dMwadQ9B+LNMbh5nPA==} cpu: [x64] os: [linux] - '@expo/modules-macros-win32-arm64@0.21.0': - resolution: {integrity: sha512-u2+LDxUKqKvos5dTnZn37Pbp6gMmdGG566dh9fO8gWrS5qcoxBDuNeeB0ZXwrfmhhiL7JtT4AhH5FO7NP8dsfQ==} + '@expo/modules-macros-win32-arm64@0.27.1': + resolution: {integrity: sha512-BcIJibqB7YxuUTf7R7gk2gbPYr6ngM0t+hTeoAhpsgS5xUlYU/TVjgrYpYOFTyYfWwgnDUv75WrJxbvVolK5xQ==} cpu: [arm64] os: [win32] - '@expo/modules-macros-win32-x64@0.21.0': - resolution: {integrity: sha512-MPtZ6mYFJBs7aKfs5PZ9HikyXxn9OnxzObRkCM4vedRLwJ6mEPRaZEzWUyi9MS2SUVU7Lt4oQZPNp+gjTVEZkw==} + '@expo/modules-macros-win32-x64@0.27.1': + resolution: {integrity: sha512-jguCWgOW2dcLY7VaReBCxU48FBX1g4DhzETm/DpD+WwxZOweiBRSwk7zaOTiTG2rifBYu4y9VYHxHrONHfOLBw==} cpu: [x64] os: [win32] @@ -1476,26 +1513,26 @@ packages: '@expo/plist@0.10.1': resolution: {integrity: sha512-nuNOqj+r/hGJbZ1Ax3jUpZaOB8UAMHUrISALJEUKaCieTflYj7224EqFWmMpriCGk9wXS1gH+9GGVnSj0tWzuQ==} - '@expo/prebuild-config@58.0.14': - resolution: {integrity: sha512-2wVzVDfXjRB3LzUbW5shOM/A+6eUUCfR+2bpZiWRBBZBtZP2HA5Zq1YgCio1tDqMGAHf2NQQDhvy/Rz1U6aC3A==} + '@expo/prebuild-config@58.0.18': + resolution: {integrity: sha512-xTfSvhBrD2UYwRmpnQCPxY06j37ZMobXiZsdRPsW07vVMHe0ox4OpMbs47qv3h6RG/gfV+uu1RgMqi8d6cxurg==} - '@expo/require-utils@58.0.4': - resolution: {integrity: sha512-DvjS5NCitXYcad2Woe1EMHJKcbhTbE77HX0BgOQGL2LFHE3q2H0n8kCWDPBOCVJ63Drap3c35nwoJwF5TQL7Tw==} + '@expo/require-utils@58.0.5': + resolution: {integrity: sha512-14dGubYUBmJjK9aqVSGnwSDs9uaxfzvtqJugtMCEWyuTa/eIvToUbzUwpKCSek6S/hYSEYcgds23vDJhXSBpbQ==} peerDependencies: typescript: ^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: typescript: optional: true - '@expo/router-server@58.0.15': - resolution: {integrity: sha512-iaPlGBpUpR0cEjZM0ScfHkWK3GCVHSMTb1pXkzhSf0vG7kkBgvtw6itZbYpEOzgoGbZfCO2we//3lhVG6898tw==} + '@expo/router-server@58.0.20': + resolution: {integrity: sha512-EqrJBFWlouh6YywiKpCVr4vjRWYon3NeRTsvULuTEVpWD0EB/8eXDxcRTfUvAtxcc6Zlf5mCEXpzE8Hf8ulA1Q==} peerDependencies: - '@expo/metro-runtime': ^58.0.14 + '@expo/metro-runtime': ^58.0.16 expo: '*' - expo-constants: ^58.0.12 - expo-font: ^58.0.9 + expo-constants: ^58.0.14 + expo-font: ^58.0.11 expo-router: '*' - expo-server: ^58.0.5 + expo-server: ^58.0.6 react: '*' react-dom: '*' react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 || ~19.3.0 @@ -1509,8 +1546,8 @@ packages: react-server-dom-webpack: optional: true - '@expo/schema-utils@58.0.2': - resolution: {integrity: sha512-w28y8SG+5ZTdDdmH2ZnCQk9wHVwGzI9NqX5V4urJUFEnxbm7I129fJRkb8RIAJmMA1WKU5h3ONmranV473uaXQ==} + '@expo/schema-utils@58.0.3': + resolution: {integrity: sha512-DwoMEpT3H+r1lU+YUEP10UcUW7sDKmfvgE7oA+zQeuGyi0XxDSDgRZUUZ5nOGp+lymmTicoIUKkoN7rByuI04g==} '@expo/sdk-runtime-versions@1.0.1': resolution: {integrity: sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==} @@ -1534,8 +1571,8 @@ packages: peerDependencies: ws: ^8.0.0 - '@expo/xcpretty@4.4.6': - resolution: {integrity: sha512-uRaZZ/IsX1WkFW69xxTZtMeRvI5ziD9yoian+FMqdhITkU6NGvIWd5uRMh2Gbo/6xstpQ4v0GtF1V5YatTwzDA==} + '@expo/xcpretty@4.4.7': + resolution: {integrity: sha512-EbxYllZ7/ozONVZd425cm4XnolfW+AmSFT5aP7Jdno6oc3WrYQcHz2CO20z/2rwWwYgztmV2PXvYH+BQ8YFjqg==} hasBin: true '@fast-csv/format@4.3.5': @@ -1583,6 +1620,10 @@ packages: '@iconify/utils@3.1.7': resolution: {integrity: sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1857,50 +1898,50 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} - '@noxcturnal/noxcturnal-darwin-arm64@0.1.5': - resolution: {integrity: sha512-hQgDaWxKYln83Uk1Hh4Liju9AvGoYh4aLd4A1ou3lGn5hxzhW2uO+z3mERtWk6BMGVazvi/yUXtp9x3IlaWyhA==} + '@noxcturnal/noxcturnal-darwin-arm64@0.1.11': + resolution: {integrity: sha512-tqvZOVmSoiJzm7QjQ+whPxCP45J2C1zCAZnzc1HhwmJrFIWw/qyA8o9Cup0aVqnjNVAN+as9GHX+DbAmkCbGlQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@noxcturnal/noxcturnal-darwin-x64@0.1.5': - resolution: {integrity: sha512-I57zR8QF7Xam+4nGD5UoIODsw0VjkVyn51iDBL3CMaDR5Lh1OxXwKZMYKtv3CtQl+RSDsMfmXBEqGYLE8lIPHg==} + '@noxcturnal/noxcturnal-darwin-x64@0.1.11': + resolution: {integrity: sha512-V2HXkK5iKFBSc2GmeZE2I2wGxBc0kMQUHxxIUBSjuO8enKIby6cEQj8WW5EAuIli622Sqw1V7f0IIz8mXB4M9g==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.5': - resolution: {integrity: sha512-gVH4XJPh8RdrdjjlU0dKeielQt/FXfvqhr8IEWEf3VeNtQs8l2rPzFPdqS0uwLWQYHLBGgB0LkuOil1KKYx1gQ==} + '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.11': + resolution: {integrity: sha512-Vf9Qbke9Hq5ToFt88XicQN0FJJuepOnPKspcKDICeYYXS2b5wQnU7uhf33QWzr1KPQhn0L0IWUCwBNhMVn6E6w==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.5': - resolution: {integrity: sha512-rzQ075fcHZ+ebsELussRjnW8WBMy1RuLdS8wSqBGhdVlPR7/NaSyoWkNxnVDT8sEO26xjqGoW/o/08ghBzr/zA==} + '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.11': + resolution: {integrity: sha512-HlwlrcfBE7JBiwWaq32R8eN4APJHQCDoQlapeK+t0wxY9s9YDMDtAnG+v7RrEz05nu7Aci21x/s8qIAA5jE+NA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.5': - resolution: {integrity: sha512-X5O0lXMrv8HGOGoXDB1PG0Hjt7Rl+MJA+seR2NecQvUAAw2gzDvF/unCV5sqTAVeHfXhBHEFGkLenHZSD44efg==} + '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.11': + resolution: {integrity: sha512-kIzTkH4rEIn8vFl42lzfK8qmKaXMwHM7txbI+XkhYKjS+2WV08yslBpwHaPGtGLXPEfx+xUTUgY5K3KVET7HKw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@noxcturnal/noxcturnal-linux-x64-musl@0.1.5': - resolution: {integrity: sha512-fkQIQHRxqldNXVmjxfeCvGOiLhLU9SLBw1As02pMA2xjyiZfiXcUn4SUrZ92ciGnyCS8/XMbx+nZSxgEc3qDPw==} + '@noxcturnal/noxcturnal-linux-x64-musl@0.1.11': + resolution: {integrity: sha512-KpC0H+v+y/v0j4EmGiopzZ2OKo01JIBrHTjTi+veYAhZbpUfwZfShdTTHEj5OjdQ5VgaJU8yaBXmX48jgAACOg==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.5': - resolution: {integrity: sha512-baeH5m/vM1nf0J2UkW8cfgiVogpM0gRO9b4PZWknXQPbv5+k+0N5sGOYl/UAO2q72qYb3Xbiu7eD8c4A2kR2SA==} + '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.11': + resolution: {integrity: sha512-Q0Q0ll/qPzakp/GBmVDB/XthqyCkfM2CE5gZ78t9Ix/bkZDe5O0G7OyNkXRsGu49VcorxoGbBgSyF6CK+OaYlQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.5': - resolution: {integrity: sha512-2faDMzom8pCG/s/uR4Czh1atz4KohI85AaSmA2vtAuVnCq+nLL4Viys3A5vUlNz9MNBmTNFxzXyF2Z5HtfxXLg==} + '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.11': + resolution: {integrity: sha512-as4HUwZkQegCcGoFzaLzlZbBqdx8BGgFhAoQsTsdJCUbNTxVH0f3R/AcRLOUJ6odfko0E2SSuJc70FI0zQ/IDg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -2362,62 +2403,62 @@ packages: peerDependencies: react-native: ^0.0.0-0 || >=0.65 <1.0 - '@react-native/asset-utils@0.88.0': - resolution: {integrity: sha512-Bj1DxQt5f4OrjdlgSVkZT37Z3hx5hUIN5AUDSqL/e99vgtTcqj5vnm067epZL1EF/QiqsniZO8Q1GSkItOakDg==} + '@react-native/asset-utils@0.88.1': + resolution: {integrity: sha512-BLb5FF7NgggGYiCsA7KKkK2qFhn0K+6iGXOI+4Ucd+G7Robzw9Gt4WsWwxLt+Od+G+/hyqfz8ASokyWFZ6VcLw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/babel-plugin-codegen@0.88.0': - resolution: {integrity: sha512-leGEnCcS7Q0hTxvzvhNtr8zGQpI1gfWqXjzC4UedsFmNMv6kIo4s70o0XbtM30ZXbpfWe4BcSC8NKWuak5sI4A==} + '@react-native/babel-plugin-codegen@0.88.1': + resolution: {integrity: sha512-2r7aeE3dXu3hawHNYHgazWQl9MJNjcnA/gxLApFK2gPMxBCLiQOHVUSaemDQz4mKPs5svulbhtDehxo6LzTzpQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/codegen@0.88.0': - resolution: {integrity: sha512-SAGg+XfzsrPiYTscbKa9bo2/H2ei2TjyxW8RTMq2Ps661kRK3AnmpMGVfrEFshEMr3FGEbuQIvj86X98kaXlRA==} + '@react-native/codegen@0.88.1': + resolution: {integrity: sha512-URPgPhnsxqeQSLyTRVzhZZ8iNXtZdR1Jop6lqaQacymvl3byghXisNIRR1YyG6PbXUfzPan5Jmz/kPhUUsZ2Yw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@babel/core': '*' - '@react-native/community-cli-plugin@0.88.0': - resolution: {integrity: sha512-WrQdXbjVt1gwAay8ZzYtpOi9ly99ksJ0VMYFdVcMe6nq3vtiRgqKRpHgCdN6ySIbtr5o+aJ+Vr7OTDY98ec/Ow==} + '@react-native/community-cli-plugin@0.88.1': + resolution: {integrity: sha512-L2CQvzsOMz4oof6AVLSzesFL1Q92nrtXpK0uQH2VwdOmzzgkiHql8WFQSo9VLvMFGJDg8+G6enrHH573ruqqWA==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@react-native-community/cli': '*' - '@react-native/metro-config': 0.88.0 + '@react-native/metro-config': 0.88.1 peerDependenciesMeta: '@react-native-community/cli': optional: true '@react-native/metro-config': optional: true - '@react-native/debugger-frontend@0.88.0': - resolution: {integrity: sha512-v5cnBaaWhDRPcQv8EC20zj53jOAKH7u2wnzzXS4JTNu+diPC+cA2+kC7rq3eKI5vGhlAcG2kDPoViRuC0AY0oA==} + '@react-native/debugger-frontend@0.88.1': + resolution: {integrity: sha512-4ZjH2093hZWWgsYs5ExqjzJX5uCS4R5yZa17eRHsQClUc6RjlV61kLdIk722sQReLL+NYtBn2RJtdcQtLCwiug==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/debugger-shell@0.88.0': - resolution: {integrity: sha512-p82zT8ZKNx7nIm/8zf8pi55sxm5KQBm8td47kzoJysTdqHLyPsRMUvDXtvCJIWadHSv7RJ6t/yiCc+IoImLBtw==} + '@react-native/debugger-shell@0.88.1': + resolution: {integrity: sha512-vrOLlxkp/417qcSUiS3x3gmdoQHYA/26oEKIWPB4EekJG0uJxKXsMR0tHIDiJWO0JH/2BO3M+507DjR1U74jQg==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/dev-middleware@0.88.0': - resolution: {integrity: sha512-sHLxVHOwfkF672ZzMepNh4d3IKbrZy3qXDKWSwN5LC5VJkzzZ5FdmSPP4N/CCn2pcKJ4oSqvojXwN4De3Xk92A==} + '@react-native/dev-middleware@0.88.1': + resolution: {integrity: sha512-0smOWE3NCdEYUwPiEdiwVXjTdvXcwSejviCM0YZB0Dj6HaQ+uu4hpSrvgERDevr5dvtSaqr8N4TdYZCLXiVgFw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/gradle-plugin@0.88.0': - resolution: {integrity: sha512-4jPSKslOfJj8NKY/jv9qmWkxZYmqPyamoS3Vs0JOFxw9t4PM6L/ea0fUsjNV0LxC9zJlvWilPJOCTOf7h8wlyw==} + '@react-native/gradle-plugin@0.88.1': + resolution: {integrity: sha512-zalxoJJ/m5kxLnBy9I1zYrx4jaPxPXdys8DeROvrSOa3pSQkTwVVXRZ4FkXCdGodVA9KR2WitplDo/URNjyvFA==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/js-polyfills@0.88.0': - resolution: {integrity: sha512-Iq6v4+EWdOLv0M1+BpADQJBVfInl6FkABXCIBSi0FbvHahEYT+Y4UJ7Cm96p18gbEN1VKzqz3nbBbwLA6bx0rQ==} + '@react-native/js-polyfills@0.88.1': + resolution: {integrity: sha512-B62O6zVMYW7kK8dxIuCwpdJ5RYcIvET6ItScm0sfsTibJs+llS0krUVSAOHnEUn3lbFMfw+o+sNdWREOAc6FfQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} - '@react-native/normalize-colors@0.88.0': - resolution: {integrity: sha512-eZCu3tGAQCfvMCPQJVY4dmoMX8ZZYBvqIGHX7lwOYyzNK+Dl8e5ky7ZoXub1Rq75oSrQXNNmSP2CDjNNXXZpFQ==} + '@react-native/normalize-colors@0.88.1': + resolution: {integrity: sha512-2SAKiLyZRf+peBLkhhJWGY5vC26w1Uj9QBgUZvjAnOxjgeEKUGBE5riPS0R7+YoqSrRL0w8ucwZB4M+vvy9HMg==} - '@react-native/virtualized-lists@0.88.0': - resolution: {integrity: sha512-zpZIOAbobdS93edwHkNUdGDzvElNeQJqukRERRPns3/ucPvuMd2G4fYpmVJSVjhxtGCcLLB1PFndLYYfwaYoBQ==} + '@react-native/virtualized-lists@0.88.1': + resolution: {integrity: sha512-Zt+OGvJE6VgmzgemZL8TSA0VomNWpHMNcG0OvLkkBQEGrzY8OdDHRfjJh7S+f+UFJF77kicRC6o6mt3A7ER9lQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} peerDependencies: '@types/react': ^19.2.0 react: '*' - react-native: 0.88.0 + react-native: 0.88.1 peerDependenciesMeta: '@types/react': optional: true @@ -3163,12 +3204,12 @@ packages: babel-plugin-transform-flow-enums@0.0.2: resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} - babel-preset-expo@58.0.18: - resolution: {integrity: sha512-H1mMGvDPiFHsQgOfZ083mhzy/Q1za1VAK9WiLsHrPvUElaXJ0oLyeacMCaboiqEgC8WrZeVFGBNKaHoV98wyiA==} + babel-preset-expo@58.0.23: + resolution: {integrity: sha512-7wdPu1QSvG7BwZ1UCKFMedJp/I4hLFvVsOjchu78YooHyxoJ/6aU8QzW8uiwZR3ov5Z9OFkRWTTcYyDP9u113w==} peerDependencies: '@babel/runtime': ^7.20.0 expo: '*' - expo-widgets: ^58.0.21 + expo-widgets: ^58.0.26 react-refresh: '>=0.14.0 <1.0.0' peerDependenciesMeta: '@babel/runtime': @@ -3199,6 +3240,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + baseline-browser-mapping@2.11.34: + resolution: {integrity: sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==} + engines: {node: '>=6.0.0'} + hasBin: true + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -3259,9 +3305,17 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + bser@2.1.2: + resolution: {integrity: sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -3321,6 +3375,9 @@ packages: caniuse-lite@1.0.30001814: resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + caniuse-lite@1.0.30001820: + resolution: {integrity: sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3736,10 +3793,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -3864,6 +3917,9 @@ packages: electron-to-chromium@1.5.448: resolution: {integrity: sha512-c3DEaf2iJbLz7XvmYGZQrzaxy6Azt2/NcWAV1r3GkibEyRNkVer6t3JevvwAQ6gW2JBGGY2N/MGMS/QV+77Ldw==} + electron-to-chromium@1.5.472: + resolution: {integrity: sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==} + electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} @@ -3975,15 +4031,15 @@ packages: resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} engines: {node: '>=8.3.0'} - expo-asset@58.0.17: - resolution: {integrity: sha512-CDYXjwNoTQh4s00pR99mkbwh6A5/eUDMqKXPoLnuTW6kYKzuQEVxLpOPTj5zg0uYpCA6KAHI294li0roYGMqHQ==} + expo-asset@58.0.19: + resolution: {integrity: sha512-9tYdhiPUJKA3OjnQQJtcP4Yrdc9TPR1JQXTomcLdDSV4XgHSXRpsnp5zsIfM6S/y30QsBFHS6G3WIBaocCiPtA==} peerDependencies: expo: '*' react: '*' react-native: '*' - expo-camera@58.0.12: - resolution: {integrity: sha512-2tqW97u6T9CUBDYc3VSrKj0BBXDYKyBOSjqOSiqidVkJMm/iKy4hngitTu7EMi2EqELqh90bGUkOeqhpYfBsdg==} + expo-camera@58.0.14: + resolution: {integrity: sha512-yEiMuJ2d7A+EqsP8rlGIvgaUzdk/rW9CXR6R7WKqludxU3H+JcSUbPC3ldr3ZpmaYlgXP3x+oOO9BfQy7yKK2A==} peerDependencies: expo: '*' react: '*' @@ -3993,19 +4049,19 @@ packages: react-native-web: optional: true - expo-constants@58.0.12: - resolution: {integrity: sha512-d3/dsps32nU4m6YJQbXQ1dIfn58GxfmJjTnZ0WtnhdSHTdG/oVsOakLqYRlX/FaLUKkQOSHXMf7hyCx+2a/HSQ==} + expo-constants@58.0.14: + resolution: {integrity: sha512-ogn/7xLmvkEVYX6SimDkcNDn/d8qZpCTQdBWPnMI+Ry7Sfch0Dz2GIcweqddK4hrkvrSY01TS20BZfMlCpJ75g==} peerDependencies: expo: '*' react-native: '*' - expo-dev-client@58.0.14: - resolution: {integrity: sha512-gyzONNNS/SLZdxFdDshgzc0Uxxt2i+MjYwOQX3LNvr3pXFtYHYM9kza8MpdYOqhqGvWnA4BebeBWq3T1hjt9nQ==} + expo-dev-client@58.0.17: + resolution: {integrity: sha512-9kGl91OWp4oxNuxAxg3C6UxgLFC78JpK4mRmNNo3B3K6OB40rZ5m6PqtqlxcZ1BWvGV8SGGoALxaYrxP68uw5A==} peerDependencies: expo: '*' - expo-dev-launcher@58.0.14: - resolution: {integrity: sha512-RFgP59Tesnr3wBPj4B82Qwx2k0355VkVmHuovzwfmuti22pWhJvNhdr2aR7BokQ5U6z2Vm4HAz6NjRE+xBIFPA==} + expo-dev-launcher@58.0.18: + resolution: {integrity: sha512-WjcuYTGn86bwz0qCpPgi/EXHfo9Pj/Z3ynHTaX5h9yaOhe+3p20bm50XVrDIOC+GAbuke8LK8bglVkpkQrIOrg==} peerDependencies: expo: '*' react-native: '*' @@ -4015,8 +4071,8 @@ packages: peerDependencies: expo: '*' - expo-dev-menu@58.0.11: - resolution: {integrity: sha512-kOqJ2wGsmM+tYf96mEy06WSBbX/cOx2KgmaouuqQX24/zpBPKlcLUQGgV75iGBXIxS6sczBO7qBp/MifFOud3w==} + expo-dev-menu@58.0.12: + resolution: {integrity: sha512-TahJsEFlYEGXLXt3SY10VkhImSHtT2Xa14eAujtmM3NefCMGqgEYj0xTqcg8O2RB4ZrGameYslnR4GkkSxPASQ==} peerDependencies: expo: '*' react-native: '*' @@ -4026,14 +4082,14 @@ packages: engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0} hasBin: true - expo-file-system@58.0.10: - resolution: {integrity: sha512-n0M7amp/Bv9TAqVfTWq1zGykmQJWiPzDePZIKp8YYKkVvY7N65bo96sMBQlHkhAyW4I1B6MZABnybliy5DXQig==} + expo-file-system@58.0.13: + resolution: {integrity: sha512-+/1aFErkawyky5UdWI06zYeO2DeHUnfaWnYHeiI8M8Z/sB6L66gI3WUxqtvUvKJoaM0Ti1CximD3x3JwteLYIg==} peerDependencies: expo: '*' react-native: '*' - expo-font@58.0.9: - resolution: {integrity: sha512-C8sIeVA2NBb76xqqnQ6eVzfXezAfKJC4qVGU9ukKM4KXdLtkhH5bRCvRSc6qUFUmrcNoM3RjI78SEH3lhigRSg==} + expo-font@58.0.11: + resolution: {integrity: sha512-6r7s6xk2L2orvum+x+TY1fzEcMtuCBRQTLgC/Su2uLzNPpT4L+E+S4IAGy8ZIjeVvwk2j2pj+HiZ23O7eh1PGQ==} peerDependencies: expo: '*' react: '*' @@ -4063,17 +4119,21 @@ packages: peerDependencies: expo: '*' - expo-modules-autolinking@58.0.13: - resolution: {integrity: sha512-uzLFoTnAKabUdITvzRqWTvFkZsOdRPEXj7AEMAuBEhA2kGhqaBDa2EOWqPXVay0QJVoZuIQaEEgPGAXN1dPuYg==} + expo-mcp@0.2.4: + resolution: {integrity: sha512-rBomlm+085wNa+UF9YC3bXGZR6LlYPfOlUXwKBB5R7+dnASk0VjWFETuxyApdtXw9OItmOsAXolUxrAlEYfqSA==} hasBin: true - expo-modules-cli@0.3.1: - resolution: {integrity: sha512-dRLz4GMRi6lETbE729xFVforu9VI2hQ+YLMtVXfukHMjm6KposDOlJGCdYoZpB6oLVMK/NShc1q9v/u4yr1dkA==} + expo-modules-autolinking@58.0.16: + resolution: {integrity: sha512-YglXoBBc3QwqqFfFQD1XVfpxXdF9RkuZOVxWTn5uHPiyvXSMFiVO16GnV+VSd+njKld1EJt7vQucCsDiLp4xVA==} + hasBin: true + + expo-modules-cli@0.4.0: + resolution: {integrity: sha512-NuvnnEFL+UMuQSjykg5ZhV0oxkOww8exJbZh+75pFF7DJhE3kQtfnOd4Yq4G6PrkVnqbeuKsqf6zX8V9p65ZYw==} engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0} hasBin: true - expo-modules-core@58.0.23: - resolution: {integrity: sha512-jsINDwUJ1ddCGMTQAROPnBHIvCyLFZNJcGT/3iFP5LcP+01edie2ZBx44NEYIPmQLb3Vnz8sC3rKouwo7vj2gQ==} + expo-modules-core@58.0.29: + resolution: {integrity: sha512-S3L38WVxcn1sQfUr9fiUgKri2n4YKdtuToF3r45V7dVIUmHN69Fc7nZPF5BiMtOl/CgPWACicqUZr7jawBxXFg==} peerDependencies: react: '*' react-native: '*' @@ -4082,16 +4142,16 @@ packages: react-native-worklets: optional: true - expo-modules-jsi@58.0.16: - resolution: {integrity: sha512-+UwXQVGBJDmwCe2MgkeVLdMIXmUjCcmMakRrtlAmA5I7WwAt5QYcbVi3u7Cae9X12xKet9IpXzzJRPz5zM32LA==} + expo-modules-jsi@58.0.21: + resolution: {integrity: sha512-6FG3jKt89s+qolbalas+OE5gQjDYu/sfV7HVAFEw3JVtkS1+MVfCvQLxp3RAm85bVNO0vgfnbn5z6A8WSDWalA==} peerDependencies: react-native: '*' - expo-modules-macros@0.21.0: - resolution: {integrity: sha512-dCzN+IH3ss9audf0tvDPgwEuQJZ0WvlC60SfUDFhms8Gal1cXkYpyU7epTH2lj+ipGqghrXXjp+fmyo5wDX+YQ==} + expo-modules-macros@0.27.1: + resolution: {integrity: sha512-xWNI+LpK4Pdmj52ygNYlxefZDhw4mpe6s5yVCLXpqtOyU3PPrFGJJJnDyBpOsOv+mXiYLeaszEEJEpDKBObZ7A==} - expo-network@58.0.3: - resolution: {integrity: sha512-0WfFHUKt08Zk7ajaFzVDcZ4iNF3ywPVhGtxo7kDSiNQI4XO3MAqB6jYvpqf0D7+KQ28o/BIEkFdwkZl6AynIvw==} + expo-network@58.0.4: + resolution: {integrity: sha512-E3dYtnku+LDal+4OtKdxGotX7DLjaq+GOvrtN0RiYDTNum8lhMB7dK4GqGgBqHjwsC0awEds5DhTkm+w7O1TtQ==} peerDependencies: expo: '*' react: '*' @@ -4101,8 +4161,8 @@ packages: peerDependencies: expo: '*' - expo-server@58.0.5: - resolution: {integrity: sha512-FHYecxfGOuAhacHcanR6qCN8Cln9S+FFulcYetLu3ddDg55ohuCsMZ9U4Egzwdm+A2o6N+MdSoJKquBC5F9NIg==} + expo-server@58.0.6: + resolution: {integrity: sha512-6DBlSZwxk3J0y1j2s4XebAWNiwGaMp32NcWX7b2kJC2+EFf1g+pCcVmx8ylawt2tyLmbDHswm4MHOfrd4bqfvA==} engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0} expo-updates-interface@58.0.1: @@ -4110,8 +4170,8 @@ packages: peerDependencies: expo: '*' - expo@58.0.16: - resolution: {integrity: sha512-LnWYyk8tYuXkNh4BSEnRhXXDtfPPJBWXOeyVtlnmRskWEyC641eqH3pDvXuX8NF2a6eg0E+rjRbZ94yO3DeM0g==} + expo@58.0.21: + resolution: {integrity: sha512-2Q8jp0fKLrDMou4aKWZ5V2cCVfcwOScuV8GBMkogiXIv7bCwwN4CIkUIq5QsX7DHXJxbruXLTXEgrp6J+iF4YA==} engines: {node: ^22.13.0 || ^24.3.0 || ^26.0.0 || >=27.0.0} hasBin: true peerDependencies: @@ -4174,6 +4234,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fb-watchman@2.0.4: + resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4227,6 +4290,10 @@ packages: resolution: {integrity: sha512-FVLYkSL/ITb/QXBEQvNWPjPooRGswuUtGOwrH+puSlMDneNkxy640+FZsk/TfKL+b7Wbw/Fg9FTUv2bMQDpj2w==} engines: {node: '>=18'} + flow-estree@0.336.0: + resolution: {integrity: sha512-0VaGWHJourzh88otU5bVJ4Z/oIsD+R6yaeR0Q8t8dezsKQI/RLi5m3fNzkiLYLhJ+YwM3JRSKlOqGplOI5Yo3w==} + engines: {node: '>=18'} + flow-parser@0.327.0: resolution: {integrity: sha512-/MYZ5hXWf5ozpbdfUDmazMgQXviLxWEiRLH3MppEGDdW0itRVqmB5UaU7QX+SBCDNiq+gEesSVn8nlxmPDyDkw==} engines: {node: '>=0.4.0'} @@ -4235,10 +4302,18 @@ packages: resolution: {integrity: sha512-vEZcHIlnKeN8JSVtYFczj8sWJfHIJOkz+YG00O/oul/Bete+t1ZhTi+O6JRRLFYvGW+yJkZ0YgL16iStnbkUqQ==} engines: {node: '>=0.4.0'} + flow-parser@0.336.0: + resolution: {integrity: sha512-O8QvQf/B3rBF+eagcc9/WMAjfJoaKckdgSd8PT/qvMIIe4ZjVKrSqV5SDnRFAKY0VrE8r5C4ADYgvYhmwkWADA==} + engines: {node: '>=0.4.0'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -4361,6 +4436,12 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -4443,8 +4524,8 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hermes-compiler@260318099.0.4: - resolution: {integrity: sha512-8odcPFkPC82HlqCl0C5QI9mBN4rEAT+IAo55sJhzwKw//K3qZloiv3ex6J2O8CuBbhdmkJozfidNsset2KH25w==} + hermes-compiler@260318099.0.5: + resolution: {integrity: sha512-4Gze+pGTGTUBRmnv6LtdOMo6ZzMMh3hEkvDiWzvuNL5LM0EO5YGhr8xh3fRjFaEqReUwEGjJGcGgEPFe9+E6Gw==} hermes-estree@0.36.1: resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} @@ -4717,6 +4798,10 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -5109,60 +5194,118 @@ packages: resolution: {integrity: sha512-orvRIGpb0yK1yMbk5YP3dhVKjmoxUkRB9fdltGJxNppEqus/1tFpNuf9KnGFR3Qjg0izoUrJ4z6WgoBvdkXPrA==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-babel-transformer@0.87.2: + resolution: {integrity: sha512-ZAfqGp0vttJFI6k1LEw//nGgTYfyVHFjcU61Ac7aPuTSaPsT/MeOi710ODfrxWkwK/y6z45NrIejjMwPv0CnxQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-cache-key@0.87.1: resolution: {integrity: sha512-scqVVPMA2c+RVO12I3wyMDp5xCkRPLsyrcG4Qc3xfU5JR0EW/0sz2tjty+5UjMUtG09uiM1KYQ8HjWg7OQXHtw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-cache-key@0.87.2: + resolution: {integrity: sha512-wYtWOqRSaYlVtQe9B5YwobOFjf30IUuZZ6FqZ5cCTzisvh1varAdFCs/CGEWxBtJqe3qcgZKSi7cP4yoR0OZEQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-cache@0.87.1: resolution: {integrity: sha512-juNPaj0Xi5tkLp8imXqvbSnEIijwKG36m7OiFFVJMqEkiURwAjkMFVek3/xpix1+LVQqO7kjVp3PvuvIM61mKQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-cache@0.87.2: + resolution: {integrity: sha512-2hWZsXhEYhhzYneNH8Q/ToZZKogcHDWhXTUloiJg+p+QGLEsDx9LXadxXXHfhPDaSRc9B6VOcB0Te02dPv/tRw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-config@0.87.1: resolution: {integrity: sha512-NmkDlc/qZAdo5gTkVTuO2AKE/khemRKRH5IA+SKfqUBJigI5GqZ5TOXhmEokhlkAqzjt8dyzKhzI6FHvXoXaTQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-config@0.87.2: + resolution: {integrity: sha512-uIlI0RMKHUXI5xEe+7HkWgk8KFDjCCx7o/G/rcyqlUVbjM7N/3rDwCuT++dqC4UzeC4Afv1jDbS+bFI8/gRlNA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-core@0.87.1: resolution: {integrity: sha512-xizzky/4+c/Mkznege8OW05j7bSdM39VPoEps61Se4+hf32UQujmbvLvIE9+UnP6kNEPVomk0Fw8NsBK9iRQmw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-core@0.87.2: + resolution: {integrity: sha512-VlNETRpQXXSyj6hSU5l0xOANSAvJ81wIH/QpXcJ+nPbU6qteofQ0tsYO1HbtBIj/5MwbXjcEeDca9CJ2N40VZA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-file-map@0.87.1: resolution: {integrity: sha512-9sPuNCojC3pS4vj+ZPY7FXmHGpdJeckXuKXQrDAfJvZc60AF8owRFTkI8XdwlwrmPIB2w++KraK9FMEXvC4wJw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-file-map@0.87.2: + resolution: {integrity: sha512-zXB6o7Oj/xVGOhHQ+dz8BIAjZqjv1S1v/sJIqqRus8XiZISNspO3DmnKWssW0Gh2XO+Sq4TAjkIuYQnBJ+lnjA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-minify-terser@0.87.1: resolution: {integrity: sha512-YK4k1oO1wV48hfE+Nbw2rJCnAgeabzrgT/xH5ha5n9gbK4p3HYFC898cERUiyTl9vRh58CNTiXRc88f7y6PfKg==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-minify-terser@0.87.2: + resolution: {integrity: sha512-+sJo/F9+Xb+duOt4xA1HgNa9FRq1dsPcdTMxVrLsT1RVvgKVuA13qMeYvhR+vdlQbuXFXIrCCDOkMphxlM2/yA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-resolver@0.87.1: resolution: {integrity: sha512-FXH/brY69wT6sPxaSW8BhMmDGD/6irmBr/cL4rUi+VWwqR3pbvdcfUOfyjcHucgQ7pe5IW3xJY21eHKLVKNumQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-resolver@0.87.2: + resolution: {integrity: sha512-5irxge/5uJ+OqPwdj4GARrSmJVMVj4mkgn5WKv83X7NP9MH/AedDStqvZaMwEaiH/mQFm5W4GDbEqx1OJJeLBg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-runtime@0.87.1: resolution: {integrity: sha512-kjeuSvInsM6OzBjkCQsWxnkrKZFMKtnkCl38oXWRI9lDGs2L9hGs9Tak9sQYJ9h3gtDujneB7wZio+irgqJKDw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-runtime@0.87.2: + resolution: {integrity: sha512-68CLgcufiI162xyhSyI3Ax1kYPmUxKV6mt7Ll6gNd6Lb5ayJ+tQ1G7G+02Hda2ekV88Vq5zTsJ5hv2qpAhZa8g==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-source-map@0.87.1: resolution: {integrity: sha512-Bqpm0PBGdy53pigIJb4HRF7QxD2pUOdgSmVh/4AWbP635kSCv+Y6ltVxUZgSnzS/QbSmzyCGijmRPlXMj2/qHw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-source-map@0.87.2: + resolution: {integrity: sha512-6jqJ/b+Ho36JmiI1pAjSWvnuDHqLCkF6Pjv4bxyjESu+18TrqvPwOS82QXQwyrr+haoc5kVnguxrgRWFhuqbEg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-symbolicate@0.87.1: resolution: {integrity: sha512-rcGvwabrg0lbXGRGRKl14PY3My3+dkno2jZYhdJEuH2DmjTtTkV5bYwgto4JtV/Lbe+sXE6GO1UGg654430uow==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true + metro-symbolicate@0.87.2: + resolution: {integrity: sha512-bAE3KvaYjlyOzBHE+FI7NnG57bGoW+n9Et1leevreuMXYEaIhN/CmfCh3fRuApoj5LnJg1rFlxCXaJsIc4P5Gg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + hasBin: true + metro-transform-plugins@0.87.1: resolution: {integrity: sha512-qMiJ/x+VfqumFJJSefKS6nj8uojzC29b2/aK8vlMuFABIqGjDucICEOXd2Yt6k/XMFAgAcaJvShUOT0HLyYaPw==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-transform-plugins@0.87.2: + resolution: {integrity: sha512-pjss163wbj3G3bEANVJtzjIsHUeSP025aSlAxAla6QvwXFedD3tMDT19a9q2KRhp5OgfrDm/FbVusJuVHA0NYg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-transform-worker@0.87.1: resolution: {integrity: sha512-VOzs3OV405FuOLYdAhqA2wsn1F9lvueDVxMDLvCKaQ44U+yknIqphJ9eMjjsGwLDZiJq9sW3DMLhjD27JxQgqQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro-transform-worker@0.87.2: + resolution: {integrity: sha512-DLpo4Xl2vShovtqXfB1JYJPAWsWjzuLlbmWYscOr0qpshjZ8Ii6JBDEnFBOVEzeXNyRG/gr697v61N25N9YvDg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + metro@0.87.1: resolution: {integrity: sha512-1oyLU9elM7hPAsKIqKooEekwxiuWr27+MZBhUROPhzcbXhMDxK64GPF/hCXwBZVp1F89ODavCrBmJtLx9WtBoQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true + metro@0.87.2: + resolution: {integrity: sha512-SPdGRafxrx7pf2bNta9qvwfX6tFMxULEEDM5irfsPH+AinubUoLqZUkguJl6bwCZOVO4hOGgjr7esiSDqPjkoQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + hasBin: true + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5381,8 +5524,8 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-forge@1.4.0: - resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + node-forge@1.4.2: + resolution: {integrity: sha512-rRtAR5XLJ8gCKd9du4qrKAsgCG1ObGP4gLeNJMa4vYSeq4bmBMOJk5IHkrviBV0q7uoAJlVd8HEQYQSVUb4FGQ==} engines: {node: '>= 6.13.0'} node-gyp@12.4.0: @@ -5397,6 +5540,10 @@ packages: resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} engines: {node: '>=18'} + node-releases@2.0.63: + resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} + engines: {node: '>=18'} + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -5410,8 +5557,8 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - noxcturnal@0.1.5: - resolution: {integrity: sha512-X/HQXJP/xn7LVQ4z1E4La0NHSl5UHdtJPG6ky4r2/Xgb7k79nGDJgBXk7I5f1V2vInWjlSCM14oSB0cUxwZS/Q==} + noxcturnal@0.1.11: + resolution: {integrity: sha512-nKgZ4mQpWz5Nry5KRqRcyCv37Uc24VH9UkNsIJJ58sC7ubyXtaiP4zfjOAsGiTVZIJ6rcfkmbYS0P4plVhdHEQ==} engines: {node: '>=18'} npm-package-arg@11.0.3: @@ -5425,6 +5572,10 @@ packages: resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + ob1@0.87.2: + resolution: {integrity: sha512-KLdsz/5QTEG2Y7lhxEOYNBZX7MkD0m3Cu11B72TBJ6sCKGXYatLJ/4WtMKoGLalQyozFYDutPRpsWpDRRA+E4g==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5549,6 +5700,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} @@ -5675,6 +5829,10 @@ packages: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -5815,8 +5973,8 @@ packages: react: '*' react-native: '*' - react-native@0.88.0: - resolution: {integrity: sha512-v/wee0nYWcammMYBnSat/e3WY/5iaf9YQOMcBrfKT/wPApPPlHp94hgZovBtwhMuthR01yc/8cGn0FpFTE/C4A==} + react-native@0.88.1: + resolution: {integrity: sha512-wajWcLZ93GCN7k4Uwh4CS4L2Ah8ohcvkOXnvIYmh4Yvuqho5j3APZ9qozK+nQrHWSgauq4h8D3HN5DKK84SrHA==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true peerDependencies: @@ -6153,6 +6311,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -6543,6 +6705,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6583,10 +6751,6 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - uuid@14.0.2: resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} hasBin: true @@ -6756,9 +6920,21 @@ packages: utf-8-validate: optional: true - xcode@3.0.1: - resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} + ws@8.22.1: + resolution: {integrity: sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==} engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xcode@4.0.0: + resolution: {integrity: sha512-dDaoWzc9EP2fp9FS9PVZUkyvFxY4y1TOV9pcKaKLk++Ei/qb97XDFEVP7GnW7Osii/TzVABJ0jq2F+9/lq0TVA==} + engines: {node: '>=14.17.0'} xlsx-populate@1.21.0: resolution: {integrity: sha512-8v2Gm8BehXo6LU7KT802QoXTPkYY1SKk5V8g/UuYZnNB3JzXqud/P99Pxr2yXeKyt+sKlCatmidz6jQNie1hRw==} @@ -6771,6 +6947,10 @@ packages: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + xml@1.0.1: resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} @@ -6848,8 +7028,8 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.6.6: - resolution: {integrity: sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} @@ -6872,6 +7052,11 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + zx@8.8.5: + resolution: {integrity: sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==} + engines: {node: '>= 12.17.0'} + hasBin: true + zxing-wasm@3.1.6: resolution: {integrity: sha512-mU7UNuvl+hOT+nlSOQTMHZ2tM8lRMsbjDIp5IO/+HmBl/FOq77q0OSZPJ3cm42A9c1MKU6Uve67FiGdkhsrT9g==} peerDependencies: @@ -7117,6 +7302,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/generator@7.29.12': + dependencies: + '@babel/parser': 7.29.12 + '@babel/types': 7.29.11 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.8 @@ -7224,6 +7417,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-string-parser@7.29.11': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@8.0.6': {} @@ -7247,6 +7442,10 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.8 + '@babel/parser@7.29.12': + dependencies: + '@babel/types': 7.29.11 + '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 @@ -7327,7 +7526,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.29.12(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 @@ -7348,7 +7547,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.29.11(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-annotate-as-pure': 7.29.7 @@ -7356,7 +7555,7 @@ snapshots: '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.12 transitivePeerDependencies: - supports-color @@ -7534,6 +7733,18 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 + '@babel/traverse@7.29.12': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.12 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.12 + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 @@ -7546,6 +7757,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/types@7.29.11': + dependencies: + '@babel/helper-string-parser': 7.29.11 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7655,7 +7871,7 @@ snapshots: hosted-git-info: 9.0.3 ignore: 7.0.8 jiti: 2.7.0 - minimatch: 10.2.6 + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) proper-lockfile: 4.1.2 quickjs-wasi: 3.6.2 semver: 7.8.5 @@ -7695,8 +7911,8 @@ snapshots: '@electron/asar@4.3.1': dependencies: - glob: 13.0.6 - minimatch: 10.2.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) '@electron/fuses@1.8.0': dependencies: @@ -7777,7 +7993,7 @@ snapshots: debug: 4.4.3 dir-compare: 4.2.0 fs-extra: 11.3.6 - minimatch: 9.0.9 + minimatch: 9.0.9(patch_hash=d82eb6ab78ab1012d55ae44d746c1f8f19d358535ebc56e5e865c5daa78cb1f4) plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7871,36 +8087,36 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@expo/cli@58.1.13(patch_hash=12f30ea7444cd7bac70249452357c3b0fc1bad94ed2841996fb82c508a7b139d)(@expo/dom-webview@58.0.2)(expo-constants@58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo@58.0.16)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': + '@expo/cli@58.1.18(@expo/dom-webview@58.0.2)(expo-constants@58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-mcp@0.2.4)(expo@58.0.21)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2)': dependencies: 2g: 1.0.1(typescript@7.0.2) '@expo/code-signing-certificates': 0.0.7 - '@expo/config': 58.0.6(typescript@7.0.2) - '@expo/config-plugins': 58.0.9(typescript@7.0.2) + '@expo/config': 58.0.10(typescript@7.0.2) + '@expo/config-plugins': 58.0.11(typescript@7.0.2) '@expo/devcert': 1.2.2 - '@expo/env': 2.5.2 - '@expo/image-utils': 0.12.5(typescript@7.0.2) - '@expo/inline-modules': 0.2.6(typescript@7.0.2) + '@expo/env': 2.5.3 + '@expo/image-utils': 0.12.6(typescript@7.0.2) + '@expo/inline-modules': 0.2.8(typescript@7.0.2) '@expo/json-file': 11.2.1 - '@expo/log-box': 58.0.12(@expo/dom-webview@58.0.2)(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@expo/log-box': 58.0.14(@expo/dom-webview@58.0.2)(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@expo/log-box-utils': 58.0.2 '@expo/metro': 58.0.0 - '@expo/metro-config': 58.0.15(expo@58.0.16)(typescript@7.0.2) + '@expo/metro-config': 58.0.20(expo@58.0.21)(typescript@7.0.2) '@expo/metro-file-map': 58.0.5(typescript@7.0.2) '@expo/osascript': 2.8.1 '@expo/package-manager': 1.14.2 '@expo/platforms': 58.0.1 '@expo/plist': 0.10.1 - '@expo/prebuild-config': 58.0.14(typescript@7.0.2) - '@expo/require-utils': 58.0.4(typescript@7.0.2) - '@expo/router-server': 58.0.15(expo-constants@58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-server@58.0.5)(expo@58.0.16)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@expo/schema-utils': 58.0.2 + '@expo/prebuild-config': 58.0.18(typescript@7.0.2) + '@expo/require-utils': 58.0.5(typescript@7.0.2) + '@expo/router-server': 58.0.20(expo-constants@58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-server@58.0.6)(expo@58.0.21)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@expo/schema-utils': 58.0.3 '@expo/spawn-async': 1.8.0 - '@expo/ws-tunnel': 2.0.1(ws@8.22.0) - '@expo/xcpretty': 4.4.6 - '@react-native/asset-utils': 0.88.0 - '@react-native/dev-middleware': 0.88.0 - '@react-native/js-polyfills': 0.88.0 + '@expo/ws-tunnel': 2.0.1(ws@8.22.1) + '@expo/xcpretty': 4.4.7 + '@react-native/asset-utils': 0.88.1 + '@react-native/dev-middleware': 0.88.1 + '@react-native/js-polyfills': 0.88.1 accepts: 1.3.8 agent-cli-detector: 0.1.7 arg: 5.0.2 @@ -7911,14 +8127,14 @@ snapshots: compression: 1.8.2 connect: 3.7.0 dnssd-advertise: 1.1.6 - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-server: 58.0.5 + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-server: 58.0.6 fetch-nodeshim: 0.4.10 getenv: 2.0.0 - glob: 13.0.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) lan-network: 0.2.1 multitars: 1.0.2 - node-forge: 1.4.0(patch_hash=50d8e3f38443736858c2c81dc2b569c18083e687f81acc5f9c17a568a7e4f6f6) + node-forge: 1.4.2 npm-package-arg: 11.0.3 ora: 3.4.0 picomatch: 4.0.7 @@ -7935,10 +8151,10 @@ snapshots: terminal-link: 2.1.1 toqr: 0.1.1 wrap-ansi: 7.0.0 - ws: 8.22.0 - zod: 4.6.6 + ws: 8.22.1 optionalDependencies: - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + expo-mcp: 0.2.4 + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -7954,22 +8170,22 @@ snapshots: '@expo/code-signing-certificates@0.0.7': dependencies: - node-forge: 1.4.0(patch_hash=50d8e3f38443736858c2c81dc2b569c18083e687f81acc5f9c17a568a7e4f6f6) + node-forge: 1.4.2 - '@expo/config-plugins@58.0.9(typescript@7.0.2)': + '@expo/config-plugins@58.0.11(typescript@7.0.2)': dependencies: '@expo/config-types': 58.0.3 '@expo/json-file': 11.2.1 '@expo/plist': 0.10.1 - '@expo/require-utils': 58.0.4(typescript@7.0.2) + '@expo/require-utils': 58.0.5(typescript@7.0.2) '@expo/sdk-runtime-versions': 1.0.1 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 - glob: 13.0.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) semver: 7.8.5 slugify: 1.6.9 - xcode: 3.0.1 + xcode: 4.0.0 xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -7977,16 +8193,15 @@ snapshots: '@expo/config-types@58.0.3': {} - '@expo/config@58.0.6(typescript@7.0.2)': + '@expo/config@58.0.10(typescript@7.0.2)': dependencies: - '@expo/config-plugins': 58.0.9(typescript@7.0.2) + '@expo/config-plugins': 58.0.11(typescript@7.0.2) '@expo/config-types': 58.0.3 '@expo/json-file': 11.2.1 '@expo/platforms': 58.0.1 - '@expo/require-utils': 58.0.4(typescript@7.0.2) - deepmerge: 4.3.1 + '@expo/require-utils': 58.0.5(typescript@7.0.2) getenv: 2.0.0 - glob: 13.0.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) resolve-workspace-root: 2.0.1 semver: 7.8.5 slugify: 1.6.9 @@ -8001,21 +8216,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@expo/devtools@58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: chalk: 4.1.2 optionalDependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - '@expo/dom-webview@58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@expo/dom-webview@58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - '@expo/env@2.5.2': + '@expo/env@2.5.3': dependencies: chalk: 4.1.2 debug: 4.4.3 @@ -8023,25 +8238,25 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/fingerprint@0.21.5': + '@expo/fingerprint@0.21.8': dependencies: - '@expo/env': 2.5.2 + '@expo/env': 2.5.3 '@expo/spawn-async': 1.8.0 arg: 5.0.2 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 - glob: 13.0.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) ignore: 5.3.2 - minimatch: 10.2.6 + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) resolve-from: 5.0.0 semver: 7.8.5 transitivePeerDependencies: - supports-color - '@expo/image-utils@0.12.5(typescript@7.0.2)': + '@expo/image-utils@0.12.6(typescript@7.0.2)': dependencies: - '@expo/require-utils': 58.0.4(typescript@7.0.2) + '@expo/require-utils': 58.0.5(typescript@7.0.2) '@expo/spawn-async': 1.8.0 chalk: 4.1.2 getenv: 2.0.0 @@ -8052,9 +8267,9 @@ snapshots: - supports-color - typescript - '@expo/inline-modules@0.2.6(typescript@7.0.2)': + '@expo/inline-modules@0.2.8(typescript@7.0.2)': dependencies: - '@expo/config-plugins': 58.0.9(typescript@7.0.2) + '@expo/config-plugins': 58.0.11(typescript@7.0.2) transitivePeerDependencies: - supports-color - typescript @@ -8064,9 +8279,9 @@ snapshots: '@babel/code-frame': 7.29.7 json5: 2.2.3 - '@expo/local-build-cache-provider@58.0.6(typescript@7.0.2)': + '@expo/local-build-cache-provider@58.0.10(typescript@7.0.2)': dependencies: - '@expo/config': 58.0.6(typescript@7.0.2) + '@expo/config': 58.0.10(typescript@7.0.2) chalk: 4.1.2 transitivePeerDependencies: - supports-color @@ -8074,49 +8289,59 @@ snapshots: '@expo/log-box-utils@58.0.2': {} - '@expo/log-box@58.0.12(@expo/dom-webview@58.0.2)(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@expo/log-box@58.0.14(@expo/dom-webview@58.0.2)(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: - '@expo/dom-webview': 58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@expo/dom-webview': 58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@expo/log-box-utils': 58.0.2 anser: 1.4.10 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) stacktrace-parser: 0.1.11 optionalDependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - '@expo/metro-config@58.0.15(expo@58.0.16)(typescript@7.0.2)': + '@expo/mcp-tunnel@0.2.4(@modelcontextprotocol/sdk@1.32.1(zod@3.25.76))': + dependencies: + '@modelcontextprotocol/sdk': 1.32.1(zod@3.25.76) + ws: 8.22.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@expo/metro-config@58.0.20(expo@58.0.21)(typescript@7.0.2)': dependencies: 2g: 1.0.1(typescript@7.0.2) '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 - '@babel/generator': 7.29.8 - '@expo/config': 58.0.6(typescript@7.0.2) - '@expo/env': 2.5.2 + '@babel/generator': 7.29.12 + '@expo/config': 58.0.10(typescript@7.0.2) + '@expo/env': 2.5.3 '@expo/json-file': 11.2.1 '@expo/metro': 58.0.0 '@expo/platforms': 58.0.1 - '@expo/require-utils': 58.0.4(typescript@7.0.2) + '@expo/require-utils': 58.0.5(typescript@7.0.2) '@expo/spawn-async': 1.8.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 - '@react-native/js-polyfills': 0.88.0 - browserslist: 4.29.3 + '@react-native/js-polyfills': 0.88.1 + browserslist: 4.29.5 chalk: 4.1.2 debug: 4.4.3 getenv: 2.0.0 - glob: 13.0.6 + glob: 13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7) hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.33.0 - noxcturnal: 0.1.5 + noxcturnal: 0.1.11 picomatch: 4.0.7 - postcss: 8.5.29 + postcss: 8.5.32 resolve-from: 5.0.0 optionalDependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) transitivePeerDependencies: - bufferutil - supports-color @@ -8156,16 +8381,16 @@ snapshots: - supports-color - utf-8-validate - '@expo/modules-macros-linux-arm64@0.21.0': + '@expo/modules-macros-linux-arm64@0.27.1': optional: true - '@expo/modules-macros-linux-x64@0.21.0': + '@expo/modules-macros-linux-x64@0.27.1': optional: true - '@expo/modules-macros-win32-arm64@0.21.0': + '@expo/modules-macros-win32-arm64@0.27.1': optional: true - '@expo/modules-macros-win32-x64@0.21.0': + '@expo/modules-macros-win32-x64@0.27.1': optional: true '@expo/osascript@2.8.1': @@ -8189,23 +8414,23 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@58.0.14(typescript@7.0.2)': + '@expo/prebuild-config@58.0.18(typescript@7.0.2)': dependencies: - '@expo/config': 58.0.6(typescript@7.0.2) - '@expo/config-plugins': 58.0.9(typescript@7.0.2) + '@expo/config': 58.0.10(typescript@7.0.2) + '@expo/config-plugins': 58.0.11(typescript@7.0.2) '@expo/config-types': 58.0.3 - '@expo/image-utils': 0.12.5(typescript@7.0.2) + '@expo/image-utils': 0.12.6(typescript@7.0.2) '@expo/json-file': 11.2.1 - '@react-native/normalize-colors': 0.88.0 + '@react-native/normalize-colors': 0.88.1 debug: 4.4.3 - expo-modules-autolinking: 58.0.13(typescript@7.0.2) + expo-modules-autolinking: 58.0.16(typescript@7.0.2) resolve-from: 5.0.0 semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript - '@expo/require-utils@58.0.4(typescript@7.0.2)': + '@expo/require-utils@58.0.5(typescript@7.0.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 @@ -8215,20 +8440,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@58.0.15(expo-constants@58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-server@58.0.5)(expo@58.0.16)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@expo/router-server@58.0.20(expo-constants@58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-server@58.0.6)(expo@58.0.21)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: debug: 4.4.3 - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-constants: 58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) - expo-font: 58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - expo-server: 58.0.5 + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-constants: 58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) + expo-font: 58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + expo-server: 58.0.6 react: 19.3.0 optionalDependencies: react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - supports-color - '@expo/schema-utils@58.0.2': {} + '@expo/schema-utils@58.0.3': {} '@expo/sdk-runtime-versions@1.0.1': {} @@ -8238,17 +8463,17 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/vector-icons@15.1.1(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@expo/vector-icons@15.1.1(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: - expo-font: 58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + expo-font: 58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - '@expo/ws-tunnel@2.0.1(ws@8.22.0)': + '@expo/ws-tunnel@2.0.1(ws@8.22.1)': dependencies: - ws: 8.22.0 + ws: 8.22.1 - '@expo/xcpretty@4.4.6': + '@expo/xcpretty@4.4.7': dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 @@ -8317,6 +8542,8 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@isaacs/cliui@9.0.0': {} + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -8377,6 +8604,28 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.32.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 2.1.3(hono@4.13.11) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.1(express@5.2.1) + hono: 4.13.11 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': dependencies: '@hono/node-server': 2.1.3(hono@4.13.11) @@ -8544,28 +8793,28 @@ snapshots: '@noble/hashes@2.2.0': {} - '@noxcturnal/noxcturnal-darwin-arm64@0.1.5': + '@noxcturnal/noxcturnal-darwin-arm64@0.1.11': optional: true - '@noxcturnal/noxcturnal-darwin-x64@0.1.5': + '@noxcturnal/noxcturnal-darwin-x64@0.1.11': optional: true - '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.5': + '@noxcturnal/noxcturnal-linux-arm64-gnu@0.1.11': optional: true - '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.5': + '@noxcturnal/noxcturnal-linux-arm64-musl@0.1.11': optional: true - '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.5': + '@noxcturnal/noxcturnal-linux-x64-gnu@0.1.11': optional: true - '@noxcturnal/noxcturnal-linux-x64-musl@0.1.5': + '@noxcturnal/noxcturnal-linux-x64-musl@0.1.11': optional: true - '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.5': + '@noxcturnal/noxcturnal-win32-arm64-msvc@0.1.11': optional: true - '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.5': + '@noxcturnal/noxcturnal-win32-x64-msvc@0.1.11': optional: true '@opentelemetry/api@1.9.1': {} @@ -9033,48 +9282,48 @@ snapshots: '@radix-ui/rect@1.1.3': {} - '@react-native-async-storage/async-storage@2.2.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))': + '@react-native-async-storage/async-storage@2.2.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))': dependencies: merge-options: 3.0.4 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - '@react-native/asset-utils@0.88.0': {} + '@react-native/asset-utils@0.88.1': {} - '@react-native/babel-plugin-codegen@0.88.0(@babel/core@7.29.7)': + '@react-native/babel-plugin-codegen@0.88.1(@babel/core@7.29.7)': dependencies: - '@babel/traverse': 7.29.8 - '@react-native/codegen': 0.88.0(@babel/core@7.29.7) + '@babel/traverse': 7.29.12 + '@react-native/codegen': 0.88.1(@babel/core@7.29.7) transitivePeerDependencies: - '@babel/core' - supports-color - '@react-native/codegen@0.88.0(@babel/core@7.29.7)': + '@react-native/codegen@0.88.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.8 + '@babel/parser': 7.29.12 flow-parser: 0.327.0 invariant: 2.2.4 nullthrows: 1.1.1 tinyglobby: 0.2.17 yargs: 17.7.3 - '@react-native/community-cli-plugin@0.88.0': + '@react-native/community-cli-plugin@0.88.1': dependencies: - '@react-native/asset-utils': 0.88.0 - '@react-native/dev-middleware': 0.88.0 + '@react-native/asset-utils': 0.88.1 + '@react-native/dev-middleware': 0.88.1 commander: 12.1.0 debug: 4.4.3 invariant: 2.2.4 - metro: 0.87.1 + metro: 0.87.2 semver: 7.8.5 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@react-native/debugger-frontend@0.88.0': {} + '@react-native/debugger-frontend@0.88.1': {} - '@react-native/debugger-shell@0.88.0': + '@react-native/debugger-shell@0.88.1': dependencies: cross-spawn: 7.0.6 debug: 4.4.3 @@ -9082,11 +9331,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/dev-middleware@0.88.0': + '@react-native/dev-middleware@0.88.1': dependencies: '@isaacs/ttlcache': 1.4.1 - '@react-native/debugger-frontend': 0.88.0 - '@react-native/debugger-shell': 0.88.0 + '@react-native/debugger-frontend': 0.88.1 + '@react-native/debugger-shell': 0.88.1 connect: 3.7.0 debug: 4.4.3 invariant: 2.2.4 @@ -9099,18 +9348,18 @@ snapshots: - supports-color - utf-8-validate - '@react-native/gradle-plugin@0.88.0': {} + '@react-native/gradle-plugin@0.88.1': {} - '@react-native/js-polyfills@0.88.0': {} + '@react-native/js-polyfills@0.88.1': {} - '@react-native/normalize-colors@0.88.0': {} + '@react-native/normalize-colors@0.88.1': {} - '@react-native/virtualized-lists@0.88.0(@types/react@19.3.0)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@react-native/virtualized-lists@0.88.1(@types/react@19.3.0)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) optionalDependencies: '@types/react': 19.3.0 @@ -9124,38 +9373,38 @@ snapshots: use-latest-callback: 0.2.6(react@19.3.0) use-sync-external-store: 1.7.0(react@19.3.0) - '@react-navigation/elements@2.9.45(@react-navigation/native@7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@react-navigation/elements@2.9.45(@react-navigation/native@7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: - '@react-navigation/native': 7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@react-navigation/native': 7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) color: 4.2.3 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - react-native-safe-area-context: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native-safe-area-context: 5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) use-latest-callback: 0.2.6(react@19.3.0) use-sync-external-store: 1.7.0(react@19.3.0) - '@react-navigation/native-stack@7.21.0(@react-navigation/native@7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-screens@4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@react-navigation/native-stack@7.21.0(@react-navigation/native@7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-screens@4.30.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: - '@react-navigation/elements': 2.9.45(@react-navigation/native@7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - '@react-navigation/native': 7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@react-navigation/elements': 2.9.45(@react-navigation/native@7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native-safe-area-context@5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@react-navigation/native': 7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) color: 4.2.3 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - react-native-safe-area-context: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - react-native-screens: 4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native-safe-area-context: 5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + react-native-screens: 4.30.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': + '@react-navigation/native@7.5.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)': dependencies: '@react-navigation/core': 7.24.0(react@19.3.0) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.20 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) standard-navigation: 0.0.8(react@19.3.0) use-latest-callback: 0.2.6(react@19.3.0) @@ -9726,7 +9975,7 @@ snapshots: js-yaml: 4.3.2 json5: 2.2.3 lazy-val: 1.0.5 - minimatch: 10.2.6 + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) pkijs: 3.4.0 plist: 3.1.0 proper-lockfile: 4.1.2 @@ -9867,9 +10116,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - babel-preset-expo@58.0.18(@babel/core@7.29.7)(@babel/runtime@7.29.10)(expo@58.0.16)(react-refresh@0.14.2): + babel-preset-expo@58.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.10)(expo@58.0.21)(react-refresh@0.14.2): dependencies: - '@babel/generator': 7.29.8 + '@babel/generator': 7.29.12 '@babel/helper-module-imports': 7.29.7 '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) @@ -9879,10 +10128,10 @@ snapshots: '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.12(@babel/core@7.29.7) '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.11(@babel/core@7.29.7) '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) @@ -9905,7 +10154,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.29.9(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/babel-plugin-codegen': 0.88.0(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.88.1(@babel/core@7.29.7) babel-plugin-react-compiler: 1.0.0 babel-plugin-react-native-web: 0.21.4 babel-plugin-syntax-hermes-parser: 0.36.1 @@ -9914,7 +10163,7 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.10 - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) transitivePeerDependencies: - '@babel/core' - supports-color @@ -9935,6 +10184,8 @@ snapshots: baseline-browser-mapping@2.11.27: {} + baseline-browser-mapping@2.11.34: {} + big-integer@1.6.52: {} bignumber.js@9.3.1: {} @@ -10007,10 +10258,22 @@ snapshots: node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + browserslist@4.29.5: + dependencies: + baseline-browser-mapping: 2.11.34 + caniuse-lite: 1.0.30001820 + electron-to-chromium: 1.5.472 + node-releases: 2.0.63 + update-browserslist-db: 1.3.4(browserslist@4.29.5) + bser@2.1.1: dependencies: node-int64: 0.4.0 + bser@2.1.2: + dependencies: + node-int64: 0.4.0 + buffer-crc32@0.2.13: {} buffer-equal-constant-time@1.0.1: {} @@ -10087,6 +10350,8 @@ snapshots: caniuse-lite@1.0.30001814: {} + caniuse-lite@1.0.30001820: {} + ccount@2.0.1: {} cfb@1.2.2: @@ -10504,8 +10769,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - deepmerge@4.3.1: {} - defaults@1.0.4: dependencies: clone: 1.0.4 @@ -10658,6 +10921,8 @@ snapshots: electron-to-chromium@1.5.448: {} + electron-to-chromium@1.5.472: {} + electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 @@ -10850,103 +11115,121 @@ snapshots: unzipper: 0.11.2 uuid: 8.3.2 - expo-asset@58.0.17(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2): + expo-asset@58.0.19(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2): dependencies: - '@expo/image-utils': 0.12.5(typescript@7.0.2) - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-constants: 58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) + '@expo/image-utils': 0.12.6(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-constants: 58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - supports-color - typescript - expo-camera@58.0.12(@types/emscripten@1.41.5)(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + expo-camera@58.0.14(@types/emscripten@1.41.5)(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: barcode-detector: 3.2.3(@types/emscripten@1.41.5) - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - '@types/emscripten' - expo-constants@58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2): + expo-constants@58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2): dependencies: - '@expo/env': 2.5.2 - '@expo/require-utils': 58.0.4(typescript@7.0.2) - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + '@expo/env': 2.5.3 + '@expo/require-utils': 58.0.5(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) transitivePeerDependencies: - supports-color - typescript - expo-dev-client@58.0.14(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): + expo-dev-client@58.0.17(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-dev-launcher: 58.0.14(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) - expo-dev-menu: 58.0.11(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) - expo-dev-menu-interface: 58.0.1(expo@58.0.16) - expo-manifests: 58.0.1(expo@58.0.16) - expo-updates-interface: 58.0.1(expo@58.0.16) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-dev-launcher: 58.0.18(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + expo-dev-menu: 58.0.12(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + expo-dev-menu-interface: 58.0.1(expo@58.0.21) + expo-manifests: 58.0.1(expo@58.0.21) + expo-updates-interface: 58.0.1(expo@58.0.21) transitivePeerDependencies: - react-native - expo-dev-launcher@58.0.14(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): + expo-dev-launcher@58.0.18(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): dependencies: - '@expo/schema-utils': 58.0.2 - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-dev-menu: 58.0.11(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) - expo-manifests: 58.0.1(expo@58.0.16) - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + '@expo/schema-utils': 58.0.3 + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-dev-menu: 58.0.12(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + expo-manifests: 58.0.1(expo@58.0.21) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - expo-dev-menu-interface@58.0.1(expo@58.0.16): + expo-dev-menu-interface@58.0.1(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-dev-menu@58.0.11(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): + expo-dev-menu@58.0.12(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-dev-menu-interface: 58.0.1(expo@58.0.16) - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-dev-menu-interface: 58.0.1(expo@58.0.21) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) expo-doctor@1.21.4: {} - expo-file-system@58.0.10(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): + expo-file-system@58.0.13(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - expo-image-loader@58.0.3(expo@58.0.16): + expo-image-loader@58.0.3(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-image-picker@58.0.10(expo@58.0.16): + expo-image-picker@58.0.10(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-image-loader: 58.0.3(expo@58.0.16) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-image-loader: 58.0.3(expo@58.0.21) expo-json-utils@58.0.1: {} - expo-keep-awake@58.0.3(expo@58.0.16)(react@19.3.0): + expo-keep-awake@58.0.3(expo@58.0.21)(react@19.3.0): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - expo-manifests@58.0.1(expo@58.0.16): + expo-manifests@58.0.1(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) expo-json-utils: 58.0.1 - expo-modules-autolinking@58.0.13(typescript@7.0.2): + expo-mcp@0.2.4: + dependencies: + '@expo/mcp-tunnel': 0.2.4(@modelcontextprotocol/sdk@1.32.1(zod@3.25.76)) + '@modelcontextprotocol/sdk': 1.32.1(zod@3.25.76) + debug: 4.4.3 + glob: 11.1.0(patch_hash=181caa5dc9cc2da79ef843bd0d516d35d1b2049a1414d5ebb5dc1c7c6245febd) + jimp-compact: 0.16.1 + resolve-from: 5.0.0 + ws: 8.22.0 + xml2js: 0.6.2 + zod: 3.25.76 + zx: 8.8.5 + transitivePeerDependencies: + - '@cfworker/json-schema' + - bufferutil + - supports-color + - utf-8-validate + + expo-modules-autolinking@58.0.16(typescript@7.0.2): dependencies: '@expo/platforms': 58.0.1 - '@expo/require-utils': 58.0.4(typescript@7.0.2) + '@expo/require-utils': 58.0.5(typescript@7.0.2) '@expo/spawn-async': 1.8.0 chalk: 4.1.2 commander: 7.2.0 @@ -10954,78 +11237,80 @@ snapshots: - supports-color - typescript - expo-modules-cli@0.3.1: + expo-modules-cli@0.4.0: dependencies: + '@babel/parser': 7.29.12 arg: 5.0.2 - expo-modules-core@58.0.23(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + expo-modules-core@58.0.29(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: - expo-modules-jsi: 58.0.16(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) - expo-modules-macros: 0.21.0 + expo-modules-jsi: 58.0.21(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + expo-modules-macros: 0.27.1 invariant: 2.2.4 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - expo-modules-jsi@58.0.16(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): + expo-modules-jsi@58.0.21(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)): dependencies: - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - expo-modules-macros@0.21.0: + expo-modules-macros@0.27.1: optionalDependencies: - '@expo/modules-macros-linux-arm64': 0.21.0 - '@expo/modules-macros-linux-x64': 0.21.0 - '@expo/modules-macros-win32-arm64': 0.21.0 - '@expo/modules-macros-win32-x64': 0.21.0 + '@expo/modules-macros-linux-arm64': 0.27.1 + '@expo/modules-macros-linux-x64': 0.27.1 + '@expo/modules-macros-win32-arm64': 0.27.1 + '@expo/modules-macros-win32-x64': 0.27.1 - expo-network@58.0.3(expo@58.0.16)(react@19.3.0): + expo-network@58.0.4(expo@58.0.21)(react@19.3.0): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) react: 19.3.0 - expo-secure-store@58.0.2(expo@58.0.16): + expo-secure-store@58.0.2(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-server@58.0.5: {} + expo-server@58.0.6: {} - expo-updates-interface@58.0.1(expo@58.0.16): + expo-updates-interface@58.0.1(expo@58.0.21): dependencies: - expo: 58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo: 58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo@58.0.16(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2): + expo@58.0.21(@babel/core@7.29.7)(@expo/dom-webview@58.0.2)(expo-mcp@0.2.4)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.10 - '@expo/cli': 58.1.13(patch_hash=12f30ea7444cd7bac70249452357c3b0fc1bad94ed2841996fb82c508a7b139d)(@expo/dom-webview@58.0.2)(expo-constants@58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo@58.0.16)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - '@expo/config': 58.0.6(typescript@7.0.2) - '@expo/config-plugins': 58.0.9(typescript@7.0.2) - '@expo/devtools': 58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - '@expo/fingerprint': 0.21.5 - '@expo/local-build-cache-provider': 58.0.6(typescript@7.0.2) - '@expo/log-box': 58.0.12(@expo/dom-webview@58.0.2)(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@expo/cli': 58.1.18(@expo/dom-webview@58.0.2)(expo-constants@58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2))(expo-font@58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0))(expo-mcp@0.2.4)(expo@58.0.21)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + '@expo/config': 58.0.10(typescript@7.0.2) + '@expo/config-plugins': 58.0.11(typescript@7.0.2) + '@expo/devtools': 58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@expo/fingerprint': 0.21.8 + '@expo/local-build-cache-provider': 58.0.10(typescript@7.0.2) + '@expo/log-box': 58.0.14(@expo/dom-webview@58.0.2)(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) '@expo/log-box-utils': 58.0.2 '@expo/metro': 58.0.0 - '@expo/metro-config': 58.0.15(expo@58.0.16)(typescript@7.0.2) + '@expo/metro-config': 58.0.20(expo@58.0.21)(typescript@7.0.2) '@ungap/structured-clone': 1.4.0 - babel-preset-expo: 58.0.18(@babel/core@7.29.7)(@babel/runtime@7.29.10)(expo@58.0.16)(react-refresh@0.14.2) - expo-asset: 58.0.17(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) - expo-constants: 58.0.12(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) - expo-file-system: 58.0.10(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) - expo-font: 58.0.9(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) - expo-keep-awake: 58.0.3(expo@58.0.16)(react@19.3.0) - expo-modules-autolinking: 58.0.13(typescript@7.0.2) - expo-modules-cli: 0.3.1 - expo-modules-core: 58.0.23(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + babel-preset-expo: 58.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.10)(expo@58.0.21)(react-refresh@0.14.2) + expo-asset: 58.0.19(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0)(typescript@7.0.2) + expo-constants: 58.0.14(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) + expo-file-system: 58.0.13(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0)) + expo-font: 58.0.11(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + expo-keep-awake: 58.0.3(expo@58.0.21)(react@19.3.0) + expo-modules-autolinking: 58.0.16(typescript@7.0.2) + expo-modules-cli: 0.4.0 + expo-modules-core: 58.0.29(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) pretty-format: 29.7.0 react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) react-refresh: 0.14.2 whatwg-url-minimum: 0.2.0 optionalDependencies: - '@expo/dom-webview': 58.0.2(expo@58.0.16)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@expo/dom-webview': 58.0.2(expo@58.0.21)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) react-dom: 19.3.0(react@19.3.0) transitivePeerDependencies: - '@babel/core' - bufferutil + - expo-mcp - expo-router - expo-widgets - react-native-worklets @@ -11100,6 +11385,10 @@ snapshots: dependencies: bser: 2.1.1 + fb-watchman@2.0.4: + dependencies: + bser: 2.1.2 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -11164,6 +11453,8 @@ snapshots: flow-estree@0.331.0: {} + flow-estree@0.336.0: {} + flow-parser@0.327.0: dependencies: flow-estree: 0.327.0 @@ -11172,10 +11463,19 @@ snapshots: dependencies: flow-estree: 0.331.0 + flow-parser@0.336.0: + dependencies: + flow-estree: 0.336.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -11324,9 +11624,18 @@ snapshots: getenv@2.0.0: {} - glob@13.0.6: + glob@11.1.0(patch_hash=181caa5dc9cc2da79ef843bd0d516d35d1b2049a1414d5ebb5dc1c7c6245febd): + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.6(patch_hash=6beb643a718eff7d7e70483b7992e3f1ddfbe50878326cd97dd817c5f5953fe7): dependencies: - minimatch: 10.2.6 + minimatch: 10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7) minipass: 7.1.3 path-scurry: 2.0.2 @@ -11436,7 +11745,7 @@ snapshots: dependencies: '@types/hast': 3.0.5 - hermes-compiler@260318099.0.4: {} + hermes-compiler@260318099.0.5: {} hermes-estree@0.36.1: {} @@ -11696,6 +12005,10 @@ snapshots: isexe@4.0.0: {} + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jake@10.9.4: dependencies: async: 3.2.6 @@ -12158,10 +12471,24 @@ snapshots: transitivePeerDependencies: - supports-color + metro-babel-transformer@0.87.2: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + flow-parser: 0.336.0 + metro-cache-key: 0.87.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + metro-cache-key@0.87.1: dependencies: flow-enums-runtime: 0.0.6 + metro-cache-key@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + metro-cache@0.87.1: dependencies: exponential-backoff: 3.1.3 @@ -12171,6 +12498,15 @@ snapshots: transitivePeerDependencies: - supports-color + metro-cache@0.87.2: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.87.2 + transitivePeerDependencies: + - supports-color + metro-config@0.87.1: dependencies: connect: 3.7.0 @@ -12185,12 +12521,32 @@ snapshots: - supports-color - utf-8-validate + metro-config@0.87.2: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.87.2 + metro-cache: 0.87.2 + metro-core: 0.87.2 + metro-runtime: 0.87.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + metro-core@0.87.1: dependencies: flow-enums-runtime: 0.0.6 lodash.throttle: 4.1.1 metro-resolver: 0.87.1 + metro-core@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.87.2 + metro-file-map@0.87.1: dependencies: debug: 4.4.3 @@ -12204,20 +12560,45 @@ snapshots: transitivePeerDependencies: - supports-color + metro-file-map@0.87.2: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.4 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + jest-worker: 29.7.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + metro-minify-terser@0.87.1: dependencies: flow-enums-runtime: 0.0.6 terser: 5.51.2 + metro-minify-terser@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.51.2 + metro-resolver@0.87.1: dependencies: flow-enums-runtime: 0.0.6 + metro-resolver@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + metro-runtime@0.87.1: dependencies: '@babel/runtime': 7.29.10 flow-enums-runtime: 0.0.6 + metro-runtime@0.87.2: + dependencies: + '@babel/runtime': 7.29.10 + flow-enums-runtime: 0.0.6 + metro-source-map@0.87.1: dependencies: '@babel/traverse': 7.29.8 @@ -12232,6 +12613,19 @@ snapshots: transitivePeerDependencies: - supports-color + metro-source-map@0.87.2: + dependencies: + '@babel/traverse': 7.29.12 + '@babel/types': 7.29.11 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.87.2 + nullthrows: 1.1.1 + ob1: 0.87.2 + source-map: 0.5.7 + transitivePeerDependencies: + - supports-color + metro-symbolicate@0.87.1: dependencies: flow-enums-runtime: 0.0.6 @@ -12243,6 +12637,17 @@ snapshots: transitivePeerDependencies: - supports-color + metro-symbolicate@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.87.2 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + metro-transform-plugins@0.87.1: dependencies: '@babel/core': 7.29.7 @@ -12254,6 +12659,17 @@ snapshots: transitivePeerDependencies: - supports-color + metro-transform-plugins@0.87.2: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.12 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.12 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + metro-transform-worker@0.87.1: dependencies: '@babel/core': 7.29.7 @@ -12274,6 +12690,26 @@ snapshots: - supports-color - utf-8-validate + metro-transform-worker@0.87.2: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.12 + '@babel/parser': 7.29.12 + '@babel/types': 7.29.11 + flow-enums-runtime: 0.0.6 + metro: 0.87.2 + metro-babel-transformer: 0.87.2 + metro-cache: 0.87.2 + metro-cache-key: 0.87.2 + metro-minify-terser: 0.87.2 + metro-source-map: 0.87.2 + metro-transform-plugins: 0.87.2 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + metro@0.87.1: dependencies: '@babel/code-frame': 7.29.7 @@ -12318,6 +12754,49 @@ snapshots: - supports-color - utf-8-validate + metro@0.87.2: + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.12 + '@babel/parser': 7.29.12 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.12 + '@babel/types': 7.29.11 + accepts: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + flow-parser: 0.336.0 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.87.2 + metro-cache: 0.87.2 + metro-cache-key: 0.87.2 + metro-config: 0.87.2 + metro-core: 0.87.2 + metro-file-map: 0.87.2 + metro-resolver: 0.87.2 + metro-runtime: 0.87.2 + metro-source-map: 0.87.2 + metro-symbolicate: 0.87.2 + metro-transform-plugins: 0.87.2 + metro-transform-worker: 0.87.2 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -12548,7 +13027,7 @@ snapshots: minimalistic-assert@1.0.1: {} - minimatch@10.2.6: + minimatch@10.2.6(patch_hash=4c80b68ecba29d8bdc1b80faeac833cf39dd28487711a846e5804e3bc044f6e7): dependencies: brace-expansion: 5.0.12 @@ -12560,7 +13039,7 @@ snapshots: dependencies: brace-expansion: 2.1.7 - minimatch@9.0.9: + minimatch@9.0.9(patch_hash=d82eb6ab78ab1012d55ae44d746c1f8f19d358535ebc56e5e865c5daa78cb1f4): dependencies: brace-expansion: 2.1.7 @@ -12614,7 +13093,7 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-forge@1.4.0(patch_hash=50d8e3f38443736858c2c81dc2b569c18083e687f81acc5f9c17a568a7e4f6f6): {} + node-forge@1.4.2: {} node-gyp@12.4.0: dependencies: @@ -12633,6 +13112,8 @@ snapshots: node-releases@2.0.57: {} + node-releases@2.0.63: {} + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -12641,19 +13122,19 @@ snapshots: normalize-url@6.1.0: {} - noxcturnal@0.1.5: + noxcturnal@0.1.11: dependencies: '@babel/code-frame': 7.29.7 '@oxc-project/types': 0.154.0 optionalDependencies: - '@noxcturnal/noxcturnal-darwin-arm64': 0.1.5 - '@noxcturnal/noxcturnal-darwin-x64': 0.1.5 - '@noxcturnal/noxcturnal-linux-arm64-gnu': 0.1.5 - '@noxcturnal/noxcturnal-linux-arm64-musl': 0.1.5 - '@noxcturnal/noxcturnal-linux-x64-gnu': 0.1.5 - '@noxcturnal/noxcturnal-linux-x64-musl': 0.1.5 - '@noxcturnal/noxcturnal-win32-arm64-msvc': 0.1.5 - '@noxcturnal/noxcturnal-win32-x64-msvc': 0.1.5 + '@noxcturnal/noxcturnal-darwin-arm64': 0.1.11 + '@noxcturnal/noxcturnal-darwin-x64': 0.1.11 + '@noxcturnal/noxcturnal-linux-arm64-gnu': 0.1.11 + '@noxcturnal/noxcturnal-linux-arm64-musl': 0.1.11 + '@noxcturnal/noxcturnal-linux-x64-gnu': 0.1.11 + '@noxcturnal/noxcturnal-linux-x64-musl': 0.1.11 + '@noxcturnal/noxcturnal-win32-arm64-msvc': 0.1.11 + '@noxcturnal/noxcturnal-win32-x64-msvc': 0.1.11 npm-package-arg@11.0.3: dependencies: @@ -12668,6 +13149,10 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 + ob1@0.87.2: + dependencies: + flow-enums-runtime: 0.0.6 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -12783,6 +13268,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + package-manager-detector@1.9.0: {} pako@1.0.11: {} @@ -12885,6 +13372,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 @@ -13021,36 +13514,36 @@ snapshots: react-is@19.3.0: {} - react-native-safe-area-context@5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + react-native-safe-area-context@5.10.1(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) - react-native-screens@4.30.0(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): + react-native-screens@4.30.0(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0) - react-native: 0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) + react-native: 0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0) warn-once: 0.1.1 - react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0): + react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0): dependencies: - '@react-native/asset-utils': 0.88.0 - '@react-native/codegen': 0.88.0(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.88.0 - '@react-native/gradle-plugin': 0.88.0 - '@react-native/normalize-colors': 0.88.0 - '@react-native/virtualized-lists': 0.88.0(@types/react@19.3.0)(react-native@0.88.0(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) + '@react-native/asset-utils': 0.88.1 + '@react-native/codegen': 0.88.1(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.88.1 + '@react-native/gradle-plugin': 0.88.1 + '@react-native/normalize-colors': 0.88.1 + '@react-native/virtualized-lists': 0.88.1(@types/react@19.3.0)(react-native@0.88.1(@babel/core@7.29.7)(@types/react@19.3.0)(react@19.3.0))(react@19.3.0) anser: 1.4.10 ansi-regex: 5.0.1 base64-js: 1.5.1 flow-enums-runtime: 0.0.6 flow-parser: 0.327.0 - hermes-compiler: 260318099.0.4 + hermes-compiler: 260318099.0.5 invariant: 2.2.4 memoize-one: 5.2.1 - metro-runtime: 0.87.1 - metro-source-map: 0.87.1 + metro-runtime: 0.87.2 + metro-source-map: 0.87.2 nullthrows: 1.1.1 pretty-format: 29.7.0 promise: 8.3.0 @@ -13506,6 +13999,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -13956,6 +14451,12 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.3.4(browserslist@4.29.5): + dependencies: + browserslist: 4.29.5 + escalade: 3.2.0 + picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 @@ -13985,8 +14486,6 @@ snapshots: utils-merge@1.0.1: {} - uuid@11.1.1: {} - uuid@14.0.2: {} uuid@8.3.2: {} @@ -14128,10 +14627,11 @@ snapshots: ws@8.22.0: {} - xcode@3.0.1: + ws@8.22.1: {} + + xcode@4.0.0: dependencies: simple-plist: 1.3.1 - uuid: 11.1.1 xlsx-populate@1.21.0: dependencies: @@ -14149,6 +14649,11 @@ snapshots: sax: 1.6.1 xmlbuilder: 11.0.1 + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + xml@1.0.1: {} xmlbuilder@10.1.1: {} @@ -14214,11 +14719,15 @@ snapshots: zlibjs@0.3.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: zod: 4.6.7 - zod@4.6.6: {} + zod@3.25.76: {} zod@4.6.7: {} @@ -14231,6 +14740,8 @@ snapshots: zwitch@2.0.4: {} + zx@8.8.5: {} + zxing-wasm@3.1.6(@types/emscripten@1.41.5): dependencies: '@types/emscripten': 1.41.5 diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 130e80929..f9ee44339 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -19,12 +19,13 @@ platform acceptance are unchanged. Every file gets a private runtime home/workspace and a POSIX process group. An assertion failure does not suppress later files. A file exceeding five minutes fails and its group is terminated; it is not retried or forced green. -The two known high-cost daemon integration fixtures, -`test/channel-workspace.test.mjs` and -`test/recovery-discovery-fair-scan.test.mjs`, have an explicit ten-minute +The known high-cost daemon integration fixture, +`test/recovery-discovery-fair-scan.test.mjs`, has an explicit ten-minute watchdog because hosted macOS runs crossed five minutes while continuing to make progress. This exception is file-scoped; every other daemon file keeps -the five-minute bound. +the five-minute bound. Channel discussion, intent, workflow and workspace +tests are separate files in the ordinary parallel queue, each with the default +five-minute watchdog; their shared support fixture is not a test entrypoint. SIGTERM gets up to five seconds before SIGKILL. Cancellation stops new work. The core queue has a 20-minute deadline and daemon has a 30-minute deadline, leaving setup and artifact upload time within their unchanged job caps. diff --git a/scripts/ci/test-shard-plan.mjs b/scripts/ci/test-shard-plan.mjs index 71ff05c8f..6d1f14cb2 100644 --- a/scripts/ci/test-shard-plan.mjs +++ b/scripts/ci/test-shard-plan.mjs @@ -8,20 +8,14 @@ export const testSuites = { core: { packageName: "@ling-stack/core", cwd: join(root, "packages/core"), platform: "linux", shardCount: 4, concurrency: 2, excluded: [], exclusive: [] }, daemon: { packageName: "@ling-stack/daemon", cwd: join(root, "packages/daemon"), platform: "darwin", shardCount: 4, concurrency: 2, excluded: ["dependency-security.test.mjs"], - // These integration fixtures are deliberately bounded more generously: - // hosted macOS runners crossed the default five-minute watchdog while - // still making forward progress. The default remains five minutes for - // every other daemon file. + // Keep the existing hosted-runner allowance for the recovery fixture. + // Split channel files use the ordinary five-minute watchdog and workers. fileTimeouts: { - "test/channel-workspace.test.mjs": 600_000, "test/recovery-discovery-fair-scan.test.mjs": 600_000, }, exclusive: ["test/acp-kimi-host-read.test.mjs", "test/acp-kimi-host-terminal.test.mjs", // Preserve the child-lifecycle fixture's exclusive execution from the PR. - "test/delegation-production-tool-surface.test.mjs", - // This large channel integration file exceeds its 5-minute file watchdog - // only when it contends with another macOS worker. - "test/channel-workspace.test.mjs"] }, + "test/delegation-production-tool-surface.test.mjs"] }, }; export async function discoverTestFiles(cwd, excluded = []) { diff --git a/scripts/ci/test-shard-plan.test.mjs b/scripts/ci/test-shard-plan.test.mjs index 4576e036d..83b1c622f 100644 --- a/scripts/ci/test-shard-plan.test.mjs +++ b/scripts/ci/test-shard-plan.test.mjs @@ -24,6 +24,19 @@ test("core and daemon plans preserve full discovery exactly once with dynamic co } }); +test("split channel suites are discovered once with ordinary parallel workers and watchdogs", async () => { + const plan = await createTestPlan(["@ling-stack/daemon"]); + const suite = plan.suites.daemon; + for (const file of ["channel-discussion", "channel-intents", "channel-workflows", "channel-workspace"]) { + const path = `test/${file}.test.mjs`; + assert.equal(suite.shards.flatMap(bucket => bucket.files).filter(file => file === path).length, 1); + assert.ok(!testSuites.daemon.exclusive.includes(path)); + assert.equal(testSuites.daemon.fileTimeouts[path], undefined); + } + assert.ok(!suite.files.some(file => file.includes("channel-workspace-fixture"))); + assert.ok(testSuites.daemon.exclusive.includes("test/delegation-production-tool-surface.test.mjs")); +}); + test("measured durations balance placement deterministically without changing coverage", () => { const files = ["test/a.test.mjs", "test/b.test.mjs", "test/c.test.mjs", "test/new.test.mjs"]; const options = { timings: { [files[0]]: 100_000, [files[1]]: 90_000, [files[2]]: 10_000 } }; diff --git a/scripts/dependency-security-glob.test.mjs b/scripts/dependency-security-glob.test.mjs new file mode 100644 index 000000000..82800f43c --- /dev/null +++ b/scripts/dependency-security-glob.test.mjs @@ -0,0 +1,427 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; + +// GHSA-3g3m-2fjr-6pw4: upstream 4682e8c9babee1b385202ede36389634e12076d7. +// Resolve from real consumers and follow the installed public export declarations. +const desktopRequire = createRequire(new URL("../apps/desktop/package.json", import.meta.url)); +const builderRequire = createRequire(desktopRequire.resolve("electron-builder")); +const appBuilderRequire = createRequire(builderRequire.resolve("app-builder-lib")); +const mobileRequire = createRequire(new URL("../apps/mobile/package.json", import.meta.url)); +const expoRequire = createRequire(mobileRequire.resolve("expo/package.json")); +const cliRequire = createRequire(expoRequire.resolve("@expo/cli/package.json")); +const mcpRequire = createRequire(expoRequire.resolve("expo-mcp/package.json")); +const asarEntry = desktopRequire.resolve("@electron/asar"); +const asarRequire = createRequire(asarEntry); +const legacyAsarEntry = appBuilderRequire.resolve("@electron/asar"); +const legacyAsarRequire = createRequire(legacyAsarEntry); +const consumers = [ + { name: "electron-builder -> app-builder-lib", require: appBuilderRequire, version: "13.0.6" }, + { name: "Expo -> CLI", require: cliRequire, version: "13.0.6" }, + { name: "Expo -> expo-mcp", require: mcpRequire, version: "11.1.0" }, + { name: "Desktop -> ASAR", require: asarRequire, version: "13.0.6" }, +]; +const timeout = 5_000; +const readJson = path => JSON.parse(fs.readFileSync(path, "utf8")); +const posix = path => path.replaceAll("\\", "/"); +const sorted = paths => paths.map(posix).sort(); + +function newDirectory(t) { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), "ling-glob-security-"))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function write(root, name, contents = name) { + const path = join(root, name); + fs.mkdirSync(dirname(path), { recursive: true }); + fs.writeFileSync(path, contents); +} + +function tree(t) { + const root = newDirectory(t); + function branch(path, depth) { + write(root, join(path, "file.txt")); + if (depth > 0) for (const child of ["a", "b"]) branch(join(path, child), depth - 1); + } + branch("", 3); + return root; +} + +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +// Queue actual directory entries, then release callbacks explicitly. Once this +// finite queue and its microtasks drain, no hidden filesystem work remains. +function controlledFs(onRead = () => {}) { + const callbacks = []; + const first = deferred(); + let reads = 0; + const record = () => { + reads++; + onRead(reads); + first.resolve(); + }; + const api = { + ...fs, + promises: { + ...fs.promises, + lstat: async (...args) => fs.lstatSync(...args), + }, + readdir(path, options, cb) { + record(); + let entries, error; + try { entries = fs.readdirSync(path, options); } + catch (err) { error = err; } + callbacks.push(() => cb(error, entries)); + }, + readdirSync(...args) { + record(); + return fs.readdirSync(...args); + }, + }; + return { + fs: api, + firstRead: first.promise, + get reads() { return reads; }, + get pending() { return callbacks.length; }, + async releaseOne() { + assert.ok(callbacks.length > 0, "a directory callback must be pending"); + callbacks.shift()(); + await nextTurn(); + }, + async drain() { + let delivered = 0; + do { + while (callbacks.length) { + assert.ok(++delivered <= 64, "bounded fixture must not schedule an unbounded traversal"); + callbacks.shift()(); + await nextTurn(); + } + await nextTurn(); + } while (callbacks.length); + }, + }; +} + +function streamResult(stream) { + const settled = deferred(); + const values = []; + let error; + stream.on("data", value => { values.push(value); }); + stream.on("error", err => { error = err; settled.resolve(); }); + stream.on("end", () => settled.resolve()); + return { values, get error() { return error; }, done: settled.promise }; +} + +const variants = []; +const glob13Variants = []; +for (const consumer of [consumers[2], consumers[0], consumers[1], consumers[3]]) { + const manifestPath = consumer.require.resolve("glob/package.json"); + const manifest = readJson(manifestPath); + assert.equal(manifest.version, consumer.version); + for (const raw of manifest.version === "13.0.6" ? [false, true] : [false]) { + const specifier = raw ? "glob/raw" : "glob"; + const declaration = manifest.exports[raw ? "./raw" : "."]; + for (const format of ["require", "import"]) { + const entry = resolve(dirname(manifestPath), declaration[format].default); + if (format === "require") assert.equal(entry, consumer.require.resolve(specifier)); + const variant = { + label: `glob@${manifest.version} ${format === "require" ? "CJS" : "ESM"}${raw ? " /raw" : " default"}`, + api: format === "require" ? consumer.require(specifier) : await import(pathToFileURL(entry).href), + }; + if (consumer === consumers[2] || consumer === consumers[0]) variants.push(variant); + if (manifest.version === "13.0.6") { + glob13Variants.push({ ...variant, label: `${consumer.name} ${variant.label}` }); + } + } + } +} + +test("glob patch registration and installed consumer digests match the frozen lock", { timeout }, async () => { + const manifest = readJson(new URL("../package.json", import.meta.url)); + const lock = appBuilderRequire("js-yaml").load(fs.readFileSync(new URL("../pnpm-lock.yaml", import.meta.url), "utf8")); + for (const consumer of consumers) { + const selector = `glob@${consumer.version}`; + const patchPath = `patches/${selector}.patch`; + assert.equal(manifest.pnpm.patchedDependencies[selector], patchPath, consumer.name); + const patch = fs.readFileSync(new URL(`../${patchPath}`, import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const digest = createHash("sha256").update(patch).digest("hex"); + assert.deepEqual(lock.patchedDependencies[selector], { hash: digest, path: patchPath }); + assert.ok(lock.snapshots[`${selector}(patch_hash=${digest})`], "lock retains an exact-version patched snapshot"); + const installedManifest = consumer.require.resolve("glob/package.json"); + const globManifest = readJson(installedManifest); + assert.equal(globManifest.version, consumer.version, consumer.name); + assert.ok(installedManifest.includes(`patch_hash=${digest}`), `${consumer.name} uses this patch, not a root duplicate`); + for (const format of ["require", "import"]) { + const entry = resolve(dirname(installedManifest), globManifest.exports["."][format].default); + assert.ok(entry.includes(`patch_hash=${digest}`), `${consumer.name} ${format} entry uses this patch`); + assert.ok(fs.statSync(entry).isFile()); + } + for (const format of ["commonjs", "esm"]) { + assert.ok(patch.includes(`+++ b/dist/${format}/walker.js`)); + } + if (consumer.version === "13.0.6") { + assert.equal(globManifest.main, "./dist/commonjs/index.js"); + assert.equal(globManifest.module, "./dist/esm/index.js"); + assert.equal(globManifest.types, "./dist/commonjs/index.d.ts"); + assert.deepEqual(globManifest.exports["."], globManifest.exports["./raw"], "default and raw retain the same code and type exports"); + assert.equal(consumer.require.resolve("glob"), consumer.require.resolve("glob/raw")); + for (const [format, directory] of [["require", "commonjs"], ["import", "esm"]]) { + const expected = `./dist/${directory}/index.js`; + assert.equal(globManifest.exports["."][format].default, expected); + assert.equal(globManifest.exports["./raw"][format].default, expected); + assert.equal(globManifest.tshy.exports["."][format].default, expected); + assert.equal(globManifest.exports["."][format].types, `./dist/${directory}/index.d.ts`); + const entry = resolve(dirname(installedManifest), expected); + const rawEntry = resolve(dirname(installedManifest), globManifest.exports["./raw"][format].default); + const api = format === "require" ? consumer.require("glob") : await import(pathToFileURL(entry).href); + const rawApi = format === "require" ? consumer.require("glob/raw") : await import(pathToFileURL(rawEntry).href); + assert.equal(api, rawApi, `${consumer.name} ${format} default and raw share their public namespace`); + assert.equal(api.glob.Glob, api.Glob); + assert.equal(api.glob.glob, api.glob); + for (const [alias, name] of [["sync", "globSync"], ["stream", "globStream"], ["streamSync", "globStreamSync"], ["iterate", "globIterate"], ["iterateSync", "globIterateSync"]]) { + assert.equal(api[alias], api[name]); + assert.equal(api.glob[alias], api[name]); + } + const entryRequire = createRequire(entry); + const minimatchManifestPath = entryRequire.resolve("minimatch/package.json"); + const minimatchManifest = readJson(minimatchManifestPath); + assert.equal(minimatchManifest.version, "10.2.6"); + const minimatchSelector = `minimatch@${minimatchManifest.version}`; + const minimatchPatchPath = `patches/${minimatchSelector}.patch`; + assert.equal(manifest.pnpm.patchedDependencies[minimatchSelector], minimatchPatchPath); + const minimatchPatch = fs.readFileSync(new URL(`../${minimatchPatchPath}`, import.meta.url), "utf8").replaceAll("\r\n", "\n"); + const minimatchDigest = createHash("sha256").update(minimatchPatch).digest("hex"); + assert.deepEqual(lock.patchedDependencies[minimatchSelector], { hash: minimatchDigest, path: minimatchPatchPath }); + const minimatchEntry = format === "require" + ? entryRequire.resolve("minimatch") + : resolve(dirname(minimatchManifestPath), minimatchManifest.exports["."].import.default); + assert.ok(minimatchManifestPath.includes(`patch_hash=${minimatchDigest}`), `${consumer.name} resolves the patched external minimatch`); + assert.ok(minimatchEntry.includes(`patch_hash=${minimatchDigest}`), `${consumer.name} ${format} loads the patched external minimatch entry`); + assert.ok(fs.statSync(minimatchEntry).isFile()); + } + } + } + assert.equal(legacyAsarRequire("glob/package.json").version, "7.2.3", "legacy ASAR is not forced across glob majors"); +}); + +// GHSA-g3hv-75wg-m249 must be fixed inside the Glob consumers actually construct, +// whether their public export is bundled or delegates to the same-version raw API. +for (const { label, api } of glob13Variants) { + test(`glob-consumer-minimatch: ${label} limits 8-segment globstar-parent expansion to braceExpandMax`, { timeout }, t => { + const glob = new api.Glob(Array(8).fill("**/../a/b").join("/"), { braceExpandMax: 64 }); + t.diagnostic(JSON.stringify({ braceExpandMax: 64, patterns: glob.patterns.length })); + assert.equal(glob.patterns.length, 64, "Glob must pass the explicit budget to its actual minimatch implementation"); + }); + + test(`glob-consumer-minimatch: ${label} skips quadratic deduplication only above 1000 patterns`, { timeout }, t => { + const small = new api.Glob("{x,*}/{0..9}", {}); + const atLimit = new api.Glob("{x,*}/{0..499}", {}); + const large = new api.Glob("{x,*}/{0..999}", {}); + t.diagnostic(JSON.stringify({ smallPatterns: small.patterns.length, atLimitPatterns: atLimit.patterns.length, largePatterns: large.patterns.length })); + assert.equal(small.patterns.length, 10, "small pattern sets retain deduplication"); + assert.equal(atLimit.patterns.length, 500, "the 1000-pattern boundary still permits deduplication"); + assert.equal(large.patterns.length, 2000, "large pattern sets skip optional pairwise deduplication"); + }); + + test(`glob-consumer-minimatch: ${label} preserves normal glob matching semantics`, { timeout }, t => { + const cwd = newDirectory(t); + for (const name of ["src/main.ts", "src/helper.js", "src/main.test.ts", "src/style.css", "src/.hidden.ts", "assets/data.json"]) write(cwd, name); + const matches = (pattern, options = {}) => sorted(new api.Glob(pattern, { cwd, ...options }).walkSync()); + const source = ["src/helper.js", "src/main.test.ts", "src/main.ts"]; + assert.equal(new api.Glob("**/../a/b/**/../c/d", {}).patterns.length, 4); + assert.deepEqual(matches("src/*.{ts,js}"), source); + assert.deepEqual(matches("src/*.{ts,js}", { dot: true }), ["src/.hidden.ts", ...source]); + assert.deepEqual(matches("src/*.{ts,js}", { ignore: "**/*.test.ts" }), ["src/helper.js", "src/main.ts"]); + assert.deepEqual(matches("src/*.{TS,JS}", { nocase: true }), source); + assert.deepEqual(matches("src\\*.{ts,js}", { windowsPathsNoEscape: true }), source); + assert.deepEqual(matches("src/../assets/*.json"), ["assets/data.json"]); + }); +} + +for (const { label, api } of variants) { + test(`behavior: ${label} stops all asynchronous reads after first-read abort`, { timeout }, async t => { + const cwd = tree(t); + const abort = new AbortController(); + const reason = new Error("first-directory-aborted"); + const io = controlledFs(reads => { if (reads === 1) abort.abort(reason); }); + let atReject; + const result = api.glob("**", { cwd, signal: abort.signal, fs: io.fs }).then( + () => ({ fulfilled: true }), + error => { atReject = io.reads; return { error }; }, + ); + await io.firstRead; + await io.drain(); + assert.equal((await result).error, reason, "original cancellation reason is preserved"); + t.diagnostic(JSON.stringify({ readsAtRejection: atReject, readsAfterDrain: io.reads })); + assert.equal(io.reads, 1, "abort must not read any descendant directories"); + assert.equal(io.reads, atReject, "rejection must not leave background traversal running"); + }); + + for (const sync of [false, true]) { + test(`behavior: ${label} ${sync ? "sync" : "async"} stream stops after first-read abort`, { timeout }, async t => { + const cwd = tree(t); + const abort = new AbortController(); + const reason = new Error("stream-directory-aborted"); + const io = controlledFs(reads => { if (reads === 1) abort.abort(reason); }); + const stream = api[sync ? "globStreamSync" : "globStream"]("**", { cwd, signal: abort.signal, fs: io.fs }); + const result = streamResult(stream); + t.after(() => stream.destroy()); + stream.resume(); + if (!sync) await io.firstRead; + await io.drain(); + await result.done; + t.diagnostic(JSON.stringify({ directoryReads: io.reads })); + assert.equal(result.error, reason); + assert.equal(io.reads, 1, "stream cancellation must not traverse the remaining tree"); + }); + } + + test(`behavior: ${label} already-aborted promise, sync walk and streams perform no reads`, { timeout }, async t => { + const cwd = tree(t); + const abort = new AbortController(); + const reason = new Error("aborted-before-walk"); + abort.abort(reason); + const io = controlledFs(); + const options = { cwd, signal: abort.signal, fs: io.fs }; + await assert.rejects(api.glob("**", options), error => error === reason); + assert.throws(() => api.globSync("**", options), error => error === reason); + for (const name of ["globStream", "globStreamSync"]) { + const stream = api[name]("**", options); + const result = streamResult(stream); + t.after(() => stream.destroy()); + stream.resume(); + await io.drain(); + await result.done; + assert.equal(result.error, reason); + } + assert.equal(io.reads, 0); + }); + + test(`behavior: ${label} aborting a synchronous walk preserves its reason and stops reading`, { timeout }, t => { + const cwd = tree(t); + const abort = new AbortController(); + const reason = new Error("sync-directory-aborted"); + const io = controlledFs(reads => { if (reads === 1) abort.abort(reason); }); + assert.throws(() => api.globSync("**", { cwd, signal: abort.signal, fs: io.fs }), error => error === reason); + assert.equal(io.reads, 1); + }); + + test(`behavior: ${label} a paused stream resumes after pending directory callbacks`, { timeout }, async t => { + const cwd = tree(t); + const io = controlledFs(); + const stream = api.globStream("**/*", { cwd, fs: io.fs }); + t.after(() => stream.destroy()); + stream.pause(); + await io.firstRead; + assert.equal(io.pending, 1, "root read is deliberately pending while the stream is paused"); + await io.releaseOne(); + assert.equal(stream.flowing, false); + assert.equal(io.pending, 2, "both child reads were started before their matches applied backpressure"); + await io.drain(); + assert.equal(io.reads, 3, "pending reads finish, but backpressure suspends their descendants"); + const result = streamResult(stream); + stream.resume(); + await io.drain(); + await result.done; + assert.equal(result.error, undefined); + assert.deepEqual(sorted(result.values), sorted(api.globSync("**/*", { cwd }))); + }); + + test(`behavior: ${label} abort during a pending paused read settles and later resume cannot restart traversal`, { timeout }, async t => { + const cwd = tree(t); + const abort = new AbortController(); + const reason = new Error("paused-pending-directory-aborted"); + const io = controlledFs(); + const stream = api.globStream("**/*", { cwd, signal: abort.signal, fs: io.fs }); + const result = streamResult(stream); + t.after(() => stream.destroy()); + stream.pause(); + await io.firstRead; + assert.equal(io.pending, 1); + abort.abort(reason); + await result.done; + stream.resume(); + await io.drain(); + assert.equal(result.error, reason); + assert.equal(io.reads, 1); + assert.equal(stream.destroyed, true); + }); + + test(`behavior: ${label} normal, dot, symlink and Windows-separator enumeration remains compatible`, { timeout }, async t => { + const cwd = newDirectory(t); + write(cwd, "root.txt"); + write(cwd, ".hidden"); + write(cwd, "nested/file.txt"); + write(cwd, "nested/.secret"); + fs.mkdirSync(join(cwd, "empty")); + fs.symlinkSync("root.txt", join(cwd, "root-link"), "file"); + fs.symlinkSync("nested", join(cwd, "dir-link"), process.platform === "win32" ? "junction" : "dir"); + const expected = ["dir-link", "empty", "nested", "nested/file.txt", "root-link", "root.txt"]; + assert.deepEqual(sorted(await api.glob("**/*", { cwd })), expected); + assert.deepEqual(sorted(api.globSync("**/*", { cwd })), expected); + assert.deepEqual(sorted(await api.glob("**/*", { cwd, dot: true })), [...expected, ".hidden", "nested/.secret"].sort()); + assert.deepEqual(sorted(await api.glob("**/*.txt", { cwd, follow: true, nodir: true })), ["dir-link/file.txt", "nested/file.txt", "root.txt"]); + const entries = await api.glob("*", { cwd, withFileTypes: true }); + assert.equal(entries.find(entry => entry.name === "dir-link").isSymbolicLink(), true); + assert.equal(entries.find(entry => entry.name === "empty").isDirectory(), true); + assert.deepEqual(sorted(await api.glob("nested\\*.txt", { cwd, windowsPathsNoEscape: true })), ["nested/file.txt"]); + assert.deepEqual(sorted(await api.glob("nested/*ILE.TXT", { cwd, nocase: true })), ["nested/file.txt"]); + assert.deepEqual(sorted(await api.glob("nested/*.txt", { cwd, dotRelative: true })), ["./nested/file.txt"]); + }); +} + +for (const [name, entry, globVersion] of [ + ["Desktop ASAR 4.3.1 (glob 13)", asarEntry, "13.0.6"], + ["builder ASAR 3.4.1 (legacy glob 7)", legacyAsarEntry, "7.2.3"], +]) { + test(`behavior: ${name} packs, unpacks and extracts real files and links`, { timeout }, async t => { + const asar = await import(pathToFileURL(entry).href); + assert.equal(createRequire(entry)("glob/package.json").version, globVersion); + const root = newDirectory(t); + const source = join(root, "source"); + write(source, "main.js", "module.exports = 42;\n"); + write(source, ".env", "TEST_FIXTURE=1\n"); + write(source, "native/addon.node", "fixture native bytes\n"); + write(source, "assets/data.txt", "fixture asset bytes\n"); + fs.symlinkSync("main.js", join(source, "linked.js"), "file"); + fs.symlinkSync("assets", join(source, "asset-link"), process.platform === "win32" ? "junction" : "dir"); + const archive = join(root, "app.asar"); + t.after(() => asar.uncache(archive)); + await asar.createPackageWithOptions(source, archive, { unpack: "**/*.node", unpackDir: "assets" }); + const names = asar.listPackage(archive).map(name => posix(name).replace(/^\//, "")); + for (const name of ["main.js", ".env", "native/addon.node", "assets/data.txt", "linked.js", "asset-link"]) { + assert.ok(names.includes(name), `archive retains ${name}`); + } + assert.equal(names.some(name => name.startsWith("asset-link/")), false, "directory links remain links, not duplicate packed descendants"); + assert.equal(asar.statFile(archive, "linked.js", false).link, "main.js"); + assert.equal(asar.statFile(archive, "asset-link", false).link, "assets"); + for (const name of ["native/addon.node", "assets/data.txt"]) { + assert.equal(asar.statFile(archive, name).unpacked, true); + assert.deepEqual(fs.readFileSync(join(`${archive}.unpacked`, name)), fs.readFileSync(join(source, name))); + assert.deepEqual(asar.extractFile(archive, name), fs.readFileSync(join(source, name))); + } + assert.deepEqual(asar.extractFile(archive, "linked.js"), fs.readFileSync(join(source, "main.js"))); + const destination = join(root, "extracted"); + asar.extractAll(archive, destination); + for (const name of ["main.js", ".env", "native/addon.node", "assets/data.txt", "linked.js"]) { + assert.deepEqual(fs.readFileSync(join(destination, name)), fs.readFileSync(join(source, name))); + } + if (process.platform !== "win32") { + assert.equal(fs.lstatSync(join(destination, "linked.js")).isSymbolicLink(), true); + assert.equal(fs.realpathSync(join(destination, "linked.js")), join(destination, "main.js")); + assert.equal(fs.lstatSync(join(destination, "asset-link")).isSymbolicLink(), true); + assert.equal(fs.realpathSync(join(destination, "asset-link")), join(destination, "assets")); + } + }); +} diff --git a/scripts/dependency-security-minimatch.test.mjs b/scripts/dependency-security-minimatch.test.mjs new file mode 100644 index 000000000..7557e65b4 --- /dev/null +++ b/scripts/dependency-security-minimatch.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const desktopRequire = createRequire(new URL("../apps/desktop/package.json", import.meta.url)); +const builderRequire = createRequire(desktopRequire.resolve("electron-builder")); +const appBuilderRequire = createRequire(builderRequire.resolve("app-builder-lib")); +const universalRequire = createRequire(appBuilderRequire.resolve("@electron/universal")); +const mobileRequire = createRequire(new URL("../apps/mobile/package.json", import.meta.url)); +const expoRequire = createRequire(mobileRequire.resolve("expo/package.json")); +const cliRequire = createRequire(expoRequire.resolve("@expo/cli/package.json")); +// Pi's public entry is import-only. Resolve its installed ESM entry, not a root copy. +const piRoot = realpathSync(join(root, "packages/runner-pi/node_modules/@earendil-works/pi-coding-agent")); +const piManifest = JSON.parse(readFileSync(join(piRoot, "package.json"), "utf8")); +const piRequire = createRequire(join(piRoot, piManifest.exports["."].import)); +const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const lock = appBuilderRequire("yaml").parse(readFileSync(join(root, "pnpm-lock.yaml"), "utf8")); + +test("Pi's actual model scoping preserves wildcard, case and thinking-level selection", async () => { + const { resolveModelScopeFromModels } = await import(pathToFileURL(join(piRoot, "dist/core/model-resolver.js")).href); + const models = [{ provider: "fixture", id: "alpha-model" }, { provider: "fixture", id: "beta-model" }]; + const result = resolveModelScopeFromModels(["FIXTURE/ALPHA*:high", "*beta*"], models); + assert.deepEqual(result.scopedModels, [ + { model: models[0], thinkingLevel: "high" }, { model: models[1], thinkingLevel: undefined }, + ]); + assert.deepEqual(result.diagnostics, []); +}); + +const legacyAsarRequire = createRequire(appBuilderRequire.resolve("@electron/asar")); +const archiverRequire = createRequire(appBuilderRequire.resolve("archiver")); +const readdirRequire = createRequire(archiverRequire.resolve("readdir-glob")); +for (const [label, dependencyRequire, version] of [ + ["builder's legacy ASAR", legacyAsarRequire, "3.1.5"], + ["archiver's readdir-glob", readdirRequire, "5.1.9"], +]) { + test(`${label} keeps its declared legacy major without the affected preprocessor`, () => { + const api = dependencyRequire("minimatch"); + assert.equal(dependencyRequire("minimatch/package.json").version, version); + assert.equal(typeof api.Minimatch.prototype.firstPhasePreProcess, "undefined"); + assert.equal(typeof api.Minimatch.prototype.secondPhasePreProcess, "undefined"); + assert.equal(api("src/main.ts", "**/*.ts"), true); + }); +} + +for (const [label, dependencyRequire, version] of [ + ["Pi", piRequire, "10.2.6"], + ["Electron builder", appBuilderRequire, "10.2.6"], + ["Electron universal", universalRequire, "9.0.9"], + ["Expo CLI", cliRequire, "10.2.6"], +]) { + const packagePath = dependencyRequire.resolve("minimatch/package.json"); + const packageRoot = dirname(packagePath); + const dependency = JSON.parse(readFileSync(packagePath, "utf8")); + const esmEntry = resolve(packageRoot, dependency.exports["."].import.default); + const cjsEntry = dependencyRequire.resolve("minimatch"); + + test(`${label} consumes the exact minimatch version and frozen patch`, () => { + assert.equal(dependency.version, version); + const key = `minimatch@${version}`; + const patchPath = manifest.pnpm.patchedDependencies[key]; + assert.equal(patchPath, `patches/${key}.patch`); + const bytes = readFileSync(join(root, patchPath), "utf8").replaceAll("\r\n", "\n"); + const digest = createHash("sha256").update(bytes).digest("hex"); + assert.equal(lock.patchedDependencies[key].hash, digest); + assert.ok(cjsEntry.includes(`patch_hash=${digest}`)); + assert.ok(esmEntry.includes(`patch_hash=${digest}`)); + }); + + for (const [format, api] of [["CJS", dependencyRequire("minimatch")], ["ESM", await import(pathToFileURL(esmEntry).href)]]) { + const { Minimatch } = api; + test(`${label} ${format} bounds globstar-parent preprocessing at the default budget (GHSA-g3hv-75wg-m249)`, () => { + const matcher = new Minimatch("literal", { optimizationLevel: 2 }); + // Start one slot below the real budget to probe growth without an unbounded PoC. + const patterns = Array(99_999).fill(["literal"]); + patterns[patterns.length - 1] = "**/../a/b/**/../c/d".split("/"); + assert.equal(matcher.firstPhasePreProcess(patterns).length, 100_000); + }); + + if (version === "10.2.6") { + test(`${label} ${format} shares the explicit brace expansion budget with preprocessing`, () => { + const matcher = new Minimatch(Array(8).fill("**/../a/b").join("/"), { + optimizationLevel: 2, braceExpandMax: 64, + }); + assert.equal(matcher.set.length, 64); + }); + } + + test(`${label} ${format} skips quadratic deduplication only for large pattern sets`, () => { + const small = new Minimatch("{x,*}/{0..9}", { optimizationLevel: 2 }); + assert.equal(small.set.length, 10); + const large = new Minimatch("{x,*}/{0..999}", { optimizationLevel: 2 }); + assert.equal(large.set.length, 2000); + assert.equal(large.match("x/5"), true); + assert.equal(large.match("y/999"), true); + assert.equal(large.match(".y/5"), false); + let comparisons = 0; + large.partsMatch = () => { comparisons++; return false; }; + const patterns = Array.from({ length: 1001 }, (_, i) => [String(i)]); + assert.equal(large.secondPhasePreProcess(patterns), patterns); + assert.equal(comparisons, 0); + }); + + test(`${label} ${format} preserves normal, negated, dotfile and Windows matching`, () => { + const matcher = new Minimatch("**/../a/b/**/../c/d", { optimizationLevel: 2 }); + assert.equal(matcher.set.length, 4); + assert.equal(matcher.match("../a/b/../c/d"), true); + assert.equal(matcher.match("x/a/b/y/c/d"), true); + assert.equal(api.minimatch("src/main.ts", "**/*.{ts,js}"), true); + assert.equal(api.minimatch("src/main.css", "**/*.{ts,js}"), false); + assert.equal(api.minimatch("src/main.ts", "!**/*.css"), true); + assert.equal(api.minimatch(".env", "*"), false); + assert.equal(api.minimatch(".env", "*", { dot: true }), true); + for (const optimizationLevel of [0, 1, 2]) { + const options = { platform: "win32", windowsPathsNoEscape: true, nocase: true, optimizationLevel }; + assert.equal(api.minimatch("C:\\src\\main.TS", "c:/src/*.ts", options), true); + assert.equal(api.minimatch("//server/share/src/main.ts", "//server/share/**/*.ts", options), true); + assert.equal(api.minimatch("//?/C:/src/main.ts", "C:/src/*.ts", options), true); + } + }); + } +} From c81c26984c533feecf30351315d5ca079f5ee5e7 Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 00:35:09 +0800 Subject: [PATCH 2/3] fix: scope reviewed historical digest false positives Add only independently verified exact historical fingerprints. Preserve full-history scanning, existing ignore entries and all scanner rules. --- .gitleaksignore | 109 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 34c18997b..683c1fee7 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -293,3 +293,112 @@ da396aaf35c5ebe8302ff2fb33ef483676f02e00:packages/daemon/test/automation-context da396aaf35c5ebe8302ff2fb33ef483676f02e00:packages/daemon/test/workspace-snapshot-queue-busy-request.test.mjs:generic-api-key:114 e949cc3527114979c2717b4afcca389a9658609e:packages/daemon/test/approval-policy-run.test.mjs:generic-api-key:321 f9f73f6cb5c4e5c13cc16758fb318a69a9de7bae:packages/providers/test/media-adapter-contract.test.mjs:generic-api-key:26 + +# Exact historical SHA-256 false positives independently verified on 2026-11-03. +# Source blobs, public Node-API headers and test fixture digests were recomputed. +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1045 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1156 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1169 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1170 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1178 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1409 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1568 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1618 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1662 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1664 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1669 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1671 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1674 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1686 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1728 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1772 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1773 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1774 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:1775 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:304 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:437 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:453 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:528 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:667 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:818 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-c01-delta-frozen-20261024.json:generic-api-key:946 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1084 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1097 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1098 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1106 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1337 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1496 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1546 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1590 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1592 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1597 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1599 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1602 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1614 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:1656 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2115 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2116 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2117 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:2118 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:232 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:365 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:381 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:456 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:595 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:746 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:874 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-delta-frozen-20261023.json:generic-api-key:973 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1016 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1127 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1140 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1141 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1149 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1380 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1539 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1589 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1633 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1635 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1640 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1642 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1645 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1657 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:1699 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2254 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2255 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2256 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:2257 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:275 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:408 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:424 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:499 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:638 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:789 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-main-delta-frozen-20261024.json:generic-api-key:917 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1037 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1148 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1161 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1162 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1170 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1403 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1562 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1612 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1656 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1658 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1663 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1665 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1668 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1680 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1722 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1885 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1886 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1887 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:1888 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:296 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:429 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:445 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:520 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:659 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:810 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/office-human-qa-p3-memory-delta-frozen-20261025.json:generic-api-key:938 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:88 +1047b00500ec478c5bf4594f25a97e73b8dd34a3:output/e6-20260928/p2-cold-bootstrap-final-regression-20261023.tap:generic-api-key:90 From 630a74b34bdb6d036b435bd5034f3c825a078a5c Mon Sep 17 00:00:00 2001 From: tianxiaochannel-oss88 Date: Tue, 3 Nov 2026 08:23:38 +0800 Subject: [PATCH 3/3] docs(ci): restore current secret-scan wording --- docs/maintenance/ci.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/maintenance/ci.md b/docs/maintenance/ci.md index f3e160b21..a0878733e 100644 --- a/docs/maintenance/ci.md +++ b/docs/maintenance/ci.md @@ -64,7 +64,7 @@ Linux 构建任务在选中 CLI 时运行真实 daemon 生命周期和新工作 | --- | --- | | 本地化类型/测试入口 | 本次修复此前被选中却未执行的漏检,并增加入口覆盖契约 | | CodeQL / Copilot Autofix | 已从默认扫描切换为上节的 PR 路径过滤、每小时检查和手动工作流;Autofix 提供修复建议,需要审查与测试,不自动合并 | -| 仓库级密钥扫描/推送保护 | GitHub 开关目前关闭;建议开启。现有完整非文档 CI 会运行 Gitleaks,但它在上传后执行,且 Draft/文档范围跳过,不能代替推送时拦截 | +| 仓库级密钥扫描/推送保护 | 2026-10-19 评估时 GitHub 开关关闭;该历史观察不代表当前设置。`complete` CI 包含文档范围的 Gitleaks 历史扫描,但它在上传后执行,Draft preview 延后扫描,不能代替推送时拦截 | | 新披露的依赖漏洞 | Dependabot 告警、安全更新及每周版本更新已配置;CI 另测已有安全补丁与许可策略。若增加漏洞严重性阻断,应先核对现存告警、补丁和无上游修复的基线,不能将旧告警直接当作本次改动引入 | | 浏览器与桌面交互 | 选中 Web 时执行六项生产预览 Playwright smoke,使用模拟 daemon 数据并保留截图;Desktop 主要只有类型检查。后续可逐步扩大交互覆盖;不把每日现有矩阵成功视为桌面打包或设备验收 |