From f4b864c0dd2975d65562a1c4c6790a41feab53f4 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 11 Aug 2026 20:14:05 +0800 Subject: [PATCH] v4.0.7-r2 (#112) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --- .github/workflows/desktop-build.yml | 1 - .github/workflows/publish.yml | 2 - nix/deepagent-code.nix | 3 - .../components/prompt-input/submit.test.ts | 4 +- .../app/src/components/prompt-input/submit.ts | 7 +- .../context/global-sync/event-reducer.test.ts | 3 +- .../session/message-timeline.data.test.ts | 153 ++++- .../pages/session/message-timeline.data.ts | 74 ++- .../src/pages/session/message-timeline.tsx | 15 +- .../src/context-federation/session-context.ts | 19 +- packages/core/src/database/migration.gen.ts | 2 + ...20260811090000_legacy_activity_progress.ts | 207 +++++++ .../20260811100000_legacy_activity_owner.ts | 32 + packages/core/test/database-migration.test.ts | 96 +++ packages/deepagent-code/package.json | 4 +- .../live-llm/activity-progress-lifecycle.ts | 114 ++++ .../live-llm/activity-progress-oracle.ts | 186 ++++++ .../script/live-llm/dispatcher.ts | 16 + .../script/live-llm/plan-advance-oracle.ts | 32 +- .../live-llm/plan-create-replan-contract.ts | 224 +++++++ .../live-llm/plan-create-replan-oracle.ts | 227 ++++++++ .../deepagent-code/script/live-llm/routes.ts | 69 ++- .../deepagent-code/script/live-llm/runtime.ts | 116 ++-- packages/deepagent-code/script/models-data.ts | 20 +- .../__tests__/subagent-permissions.test.ts | 2 +- packages/deepagent-code/src/agent/agent.ts | 4 +- .../src/agent/pr-collaboration.ts | 2 +- .../src/session/activity-crash-test.ts | 21 + .../src/session/activity-owner.ts | 5 + .../src/session/activity-sql.ts | 73 +++ .../deepagent-code/src/session/compaction.ts | 41 +- .../deepagent-code/src/session/message-v2.ts | 83 ++- .../deepagent-code/src/session/processor.ts | 39 +- .../src/session/prompt-epoch.ts | 4 +- .../src/session/prompt-intent.ts | 546 +++++++++++++++++- packages/deepagent-code/src/session/prompt.ts | 151 ++++- packages/deepagent-code/src/session/steer.ts | 182 +++++- packages/deepagent-code/src/tool/git_read.ts | 2 +- .../deepagent-code/src/tool/plan-write.ts | 124 ++-- .../deepagent-code/src/tool/plan-write.txt | 8 +- .../src/tool/semantic-fingerprint.ts | 48 +- packages/deepagent-code/src/tool/task.ts | 2 +- .../live-llm-activity-progress-oracle.test.ts | 112 ++++ ...live-llm-plan-create-replan-oracle.test.ts | 197 +++++++ .../test/script/live-llm-routes.test.ts | 79 +++ .../test/script/models-data.test.ts | 53 +- .../test/server/httpapi-provider.test.ts | 31 - .../test/session/compaction.test.ts | 33 +- .../test/session/message-v2.test.ts | 290 +++++++++- .../test/session/prompt-intent.test.ts | 276 ++++++++- .../test/session/prompt.test.ts | 175 +++++- .../deepagent-code/test/session/steer.test.ts | 125 +++- .../session/tool-sequence-tracker.test.ts | 69 ++- .../test/tool/plan-write.test.ts | 209 +++++-- packages/desktop/package.json | 2 + .../live-llm/activity-progress-package.ts | 386 +++++++++++++ .../live-llm/activity-progress-restart.ts | 243 ++++++++ packages/desktop/scripts/live-llm/runtime.ts | 280 +++++---- script/run-live-llm-all.ts | 28 + 59 files changed, 5087 insertions(+), 464 deletions(-) create mode 100644 packages/core/src/database/migration/20260811090000_legacy_activity_progress.ts create mode 100644 packages/core/src/database/migration/20260811100000_legacy_activity_owner.ts create mode 100644 packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts create mode 100644 packages/deepagent-code/script/live-llm/activity-progress-oracle.ts create mode 100644 packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts create mode 100644 packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts create mode 100644 packages/deepagent-code/src/session/activity-crash-test.ts create mode 100644 packages/deepagent-code/src/session/activity-owner.ts create mode 100644 packages/deepagent-code/src/session/activity-sql.ts create mode 100644 packages/deepagent-code/test/script/live-llm-activity-progress-oracle.test.ts create mode 100644 packages/deepagent-code/test/script/live-llm-plan-create-replan-oracle.test.ts create mode 100644 packages/desktop/scripts/live-llm/activity-progress-package.ts create mode 100644 packages/desktop/scripts/live-llm/activity-progress-restart.ts diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 7ffeff70..96a877ee 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -121,7 +121,6 @@ jobs: working-directory: packages/desktop env: DEEPAGENT_CODE_CHANNEL: ${{ github.event_name == 'push' && 'prod' || (github.event.inputs.channel || 'prod') }} - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json NODE_OPTIONS: --max-old-space-size=4096 run: bun run build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b38a5f02..cf05fff4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -94,7 +94,6 @@ jobs: env: DEEPAGENT_CODE_VERSION: ${{ needs.version.outputs.version }} DEEPAGENT_CODE_RELEASE: ${{ needs.version.outputs.release }} - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json GH_REPO: ${{ needs.version.outputs.repo }} GH_TOKEN: ${{ steps.committer.outputs.token }} @@ -327,7 +326,6 @@ jobs: working-directory: packages/desktop env: DEEPAGENT_CODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/deepagent-code/test/tool/fixtures/models-api.json SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }} diff --git a/nix/deepagent-code.nix b/nix/deepagent-code.nix index a5c55569..42a4c384 100644 --- a/nix/deepagent-code.nix +++ b/nix/deepagent-code.nix @@ -6,7 +6,6 @@ nodejs, sysctl, makeBinaryWrapper, - models-dev, ripgrep, installShellFiles, versionCheckHook, @@ -28,7 +27,6 @@ stdenvNoCC.mkDerivation (finalAttrs: { nodejs # for patchShebangs node_modules installShellFiles makeBinaryWrapper - models-dev writableTmpDirAsHomeHook ]; @@ -42,7 +40,6 @@ stdenvNoCC.mkDerivation (finalAttrs: { runHook postConfigure ''; - env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; env.DEEPAGENT_CODE_DISABLE_MODELS_FETCH = true; env.DEEPAGENT_CODE_VERSION = finalAttrs.version; env.DEEPAGENT_CODE_CHANNEL = "prod"; diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 0d18e6f9..66e15b0f 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -447,7 +447,7 @@ describe("prompt submit worktree selection", () => { expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }]) }) - test("includes the selected variant on optimistic prompts", async () => { + test("keeps an optimistic steer visible after its durable receipt", async () => { params = { id: "session-1" } variant = "high" @@ -480,7 +480,7 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) - expect(optimisticRemoved).toHaveLength(1) + expect(optimisticRemoved).toHaveLength(0) }) test("seeds new sessions before optimistic prompts are added", async () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9b4a4e00..9992d5e0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -437,10 +437,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) { input.onPromptInput?.({ promptInput, optimisticParts: submittedParts.optimisticParts }) const admission = await input.client.session.promptAsync(promptInput) if (!admission.data?.messageID) throw new Error("Prompt admission returned no durable receipt") - // The server may mint a different canonical ID for a busy-session steer. The durable event is - // authoritative, so remove a mismatched client-keyed placeholder once admission succeeds instead of - // leaving it around to render beside the canonical server message. - if (admission.data.messageID !== messageID) remove() + // A chat steer is only projected into canonical history at the next provider boundary. Keep the + // client-keyed placeholder visible until that correlated message.updated event replaces it. + if (admission.data.messageID !== messageID && admission.data.delivery !== "steer") remove() return true } catch (err) { batch(() => { diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index b99973d4..a69add1a 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -430,7 +430,7 @@ describe("applyDirectoryEvent", () => { expect(store.part.msg_2).toBeUndefined() }) - test("reconciles a canonical steer event before its HTTP receipt", () => { + test("replaces a retained optimistic steer when its canonical event arrives", () => { const sessionID = "ses_1" const clientMessageID = "msg_client" const canonical = { @@ -462,6 +462,7 @@ describe("applyDirectoryEvent", () => { }) expect(store.message[sessionID]?.map((message) => message.id)).toEqual([canonical.id]) + expect(store.message[sessionID]).toHaveLength(1) expect(store.part[clientMessageID]).toBeUndefined() expect(store.part_text_accum_delta[clientPart.id]).toBeUndefined() }) diff --git a/packages/app/src/pages/session/message-timeline.data.test.ts b/packages/app/src/pages/session/message-timeline.data.test.ts index 60803334..da70ed25 100644 --- a/packages/app/src/pages/session/message-timeline.data.test.ts +++ b/packages/app/src/pages/session/message-timeline.data.test.ts @@ -1,9 +1,14 @@ import { afterAll, describe, expect, mock, test } from "bun:test" -import type { Part, UserMessage } from "@deepagent-code/sdk/v2/client" +import type { AssistantMessage, Part, UserMessage } from "@deepagent-code/sdk/v2/client" mock.module("@deepagent-code/ui/message-part", () => ({ - groupParts: () => [], - renderable: () => false, + groupParts: (refs: { messageID: string; part: Part }[]) => + refs.map((item) => ({ + key: `part:${item.messageID}:${item.part.id}`, + type: "part", + ref: { messageID: item.messageID, partID: item.part.id }, + })), + renderable: () => true, })) afterAll(() => mock.restore()) @@ -39,3 +44,145 @@ describe("message timeline compaction", () => { ) }) }) + +describe("message timeline activity progress", () => { + const user = { + id: "msg_user", + sessionID: "ses_1", + role: "user", + agent: "build", + model: { providerID: "deepseek", modelID: "deepseek-chat" }, + time: { created: 1 }, + } as UserMessage + const assistant = (id: string) => + ({ + id, + sessionID: user.sessionID, + parentID: user.id, + role: "assistant", + mode: "build", + agent: "build", + modelID: "deepseek-chat", + providerID: "deepseek", + path: { cwd: "/project", root: "/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, completed: 2 }, + finish: "tool-calls", + }) as AssistantMessage + const progress = (messageID: string, revision: number, state: "progress" | "final") => + ({ + id: `prt_${state}_${revision}`, + sessionID: user.sessionID, + messageID, + type: "text", + text: `revision ${revision}`, + metadata: { + deepagent_activity_progress: { + activity_id: "activity-1", + revision, + state, + }, + }, + }) as Part + + test("shows only the latest settled progress for one activity", async () => { + const { Timeline } = await import("./message-timeline.data") + const messages = [assistant("msg_a0"), assistant("msg_a1")] + const parts = new Map([ + [messages[0].id, [progress(messages[0].id, 0, "progress")]], + [messages[1].id, [progress(messages[1].id, 1, "progress")]], + ]) + + const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false) + expect( + rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])), + ).toEqual(["prt_progress_1"]) + }) + + test("replaces settled progress with the activity final", async () => { + const { Timeline } = await import("./message-timeline.data") + const messages = [assistant("msg_a0"), assistant("msg_a1"), { ...assistant("msg_a2"), finish: "stop" }] + const parts = new Map([ + [messages[0].id, [progress(messages[0].id, 0, "progress")]], + [messages[1].id, [progress(messages[1].id, 1, "progress")]], + [messages[2].id, [progress(messages[2].id, 2, "final")]], + ]) + + const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false) + expect( + rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])), + ).toEqual(["prt_final_2"]) + }) + + test("collapses every text part in one activity across separate parent user rows", async () => { + const { Timeline } = await import("./message-timeline.data") + const user2 = { ...user, id: "msg_user_2" } + const firstAssistant = assistant("msg_cross_a0") + const secondAssistant = { ...assistant("msg_cross_a1"), parentID: user2.id } + const plain = (messageID: string, id: string, text: string) => + ({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part + const messages = [firstAssistant, secondAssistant] + const parts = new Map([ + [ + firstAssistant.id, + [progress(firstAssistant.id, 0, "progress"), plain(firstAssistant.id, "prt_cross_old_plain", "old detail")], + ], + [ + secondAssistant.id, + [ + progress(secondAssistant.id, 1, "progress"), + plain(secondAssistant.id, "prt_cross_latest_plain", "latest detail"), + ], + ], + ]) + const getParts = (id: string) => parts.get(id) ?? [] + const visibility = Timeline.activityProgressVisibility(messages, getParts) + const firstRows = Timeline.constructMessageRows( + user, + getParts, + [firstAssistant], + 0, + false, + "idle", + false, + visibility, + ) + const secondRows = Timeline.constructMessageRows( + user2, + getParts, + [secondAssistant], + 1, + false, + "idle", + false, + visibility, + ) + expect(firstRows.some((row) => row._tag === "AssistantPart")).toBe(false) + expect( + secondRows.flatMap((row) => + row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [], + ), + ).toEqual(["prt_progress_1", "prt_cross_latest_plain"]) + }) + + test("applies one revision marker to every text part in the assistant message", async () => { + const { Timeline } = await import("./message-timeline.data") + const messages = [assistant("msg_multi_a0"), { ...assistant("msg_multi_a1"), finish: "stop" }] + const plain = (messageID: string, id: string, text: string) => + ({ id, sessionID: user.sessionID, messageID, type: "text", text }) as Part + const parts = new Map([ + [messages[0].id, [progress(messages[0].id, 0, "progress"), plain(messages[0].id, "prt_old_plain", "old detail")]], + [ + messages[1].id, + [progress(messages[1].id, 1, "final"), plain(messages[1].id, "prt_final_plain", "final detail")], + ], + ]) + + const rows = Timeline.constructMessageRows(user, (id) => parts.get(id) ?? [], messages, 0, false, "idle", false) + + expect( + rows.flatMap((row) => (row._tag === "AssistantPart" && row.group.type === "part" ? [row.group.ref.partID] : [])), + ).toEqual(["prt_final_1", "prt_final_plain"]) + }) +}) diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index 4ae5e699..df35b468 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -139,6 +139,7 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus["type"], isActive: boolean, + activityProgressVisibility?: ReadonlySet, ) { const rows: TimelineRow.TimelineRow[] = [] @@ -150,10 +151,13 @@ export namespace Timeline { const interrupted = interruptedMessageIndex !== -1 const error = assistantMessages.find((m) => m.error && m.error.name !== "MessageAbortedError")?.error - const assistantPartRefs = assistantMessages.flatMap((message, messageIndex) => - getMessageParts(message.id) - .filter((part) => renderable(part, showReasoning)) - .map((part) => ({ messageID: message.id, messageIndex, part })), + const assistantPartRefs = latestActivityProgress( + assistantMessages.flatMap((message, messageIndex) => + getMessageParts(message.id) + .filter((part) => renderable(part, showReasoning)) + .map((part) => ({ messageID: message.id, messageIndex, part })), + ), + activityProgressVisibility, ) const assistantItems = interrupted && !compaction @@ -276,6 +280,68 @@ export namespace Timeline { return rows } + export function activityProgressVisibility( + assistantMessages: AssistantMessage[], + getMessageParts: (messageID: string) => Part[], + ) { + const refs = assistantMessages.flatMap((message, messageIndex) => + getMessageParts(message.id).map((part) => ({ messageID: message.id, messageIndex, part })), + ) + return new Set(latestActivityProgress(refs).map((ref) => `${ref.messageID}:${ref.part.id}`)) + } + + function latestActivityProgress( + refs: T[], + visibility?: ReadonlySet, + ) { + const progressByMessage = new Map>>() + refs.forEach((ref) => { + const marker = activityProgress(ref.part) + if (marker) progressByMessage.set(ref.messageID, marker) + }) + const markerFor = (ref: T) => + activityProgress(ref.part) ?? (ref.part.type === "text" ? progressByMessage.get(ref.messageID) : undefined) + if (visibility) + return refs.filter((ref) => { + if (!markerFor(ref)) return true + return visibility.has(`${ref.messageID}:${ref.part.id}`) + }) + const selected = new Map() + refs.forEach((ref) => { + const marker = markerFor(ref) + if (!marker) return + const terminal = marker.state !== "progress" + const current = selected.get(marker.activityID) + if ( + current && + ((current.terminal && !terminal) || (current.terminal === terminal && current.revision > marker.revision)) + ) + return + selected.set(marker.activityID, { revision: marker.revision, terminal }) + }) + return refs.filter((ref) => { + const marker = markerFor(ref) + if (!marker) return true + const current = selected.get(marker.activityID) + return current?.revision === marker.revision && current.terminal === (marker.state !== "progress") + }) + } + + function activityProgress(part: Part) { + if (part.type !== "text") return + const value = part.metadata?.deepagent_activity_progress + if (!value || typeof value !== "object") return + const marker = value as Record + if (typeof marker.activity_id !== "string" || marker.activity_id.length === 0) return + if (typeof marker.revision !== "number" || !Number.isInteger(marker.revision) || marker.revision < 0) return + if (!["progress", "final", "interrupted", "recovery_required"].includes(String(marker.state))) return + return { + activityID: marker.activity_id, + revision: marker.revision, + state: marker.state as "progress" | "final" | "interrupted" | "recovery_required", + } + } + function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index da3a871b..f3db8342 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -322,7 +322,9 @@ export function MessageTimeline(props: { return sync.data.message[id] ?? emptyMessages }) const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) - const sessionByID = createMemo(() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const))) + const sessionByID = createMemo( + () => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const)), + ) const assistantMessagesByParent = createMemo(() => { const result = new Map() for (const message of sessionMessages()) { @@ -406,9 +408,7 @@ export function MessageTimeline(props: { // Fork lineage carried on the session's own metadata (set by backend fork()). Drives the // full-width "derived from ‹parent›" banner at the top of the forked transcript. const forkedFrom = createMemo(() => { - const value = info()?.metadata?.forkedFrom as - | { parentSessionID?: string; parentTitle?: string } - | undefined + const value = info()?.metadata?.forkedFrom as { parentSessionID?: string; parentTitle?: string } | undefined if (!value?.parentSessionID) return undefined return { parentSessionID: value.parentSessionID, parentTitle: value.parentTitle ?? "" } }) @@ -424,6 +424,12 @@ export function MessageTimeline(props: { }) const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts + const activityProgressVisibility = createMemo(() => + Timeline.activityProgressVisibility( + sessionMessages().filter((message): message is AssistantMessage => message.role === "assistant"), + getMsgParts, + ), + ) const childTaskDescription = createMemo(() => { const id = sessionID() if (!id) return @@ -454,6 +460,7 @@ export function MessageTimeline(props: { settings.general.showReasoningSummaries(), sessionStatus().type, activeMessageID() === userMessage.id, + activityProgressVisibility(), ) return reuseTimelineRows(previous, rows) diff --git a/packages/core/src/context-federation/session-context.ts b/packages/core/src/context-federation/session-context.ts index 507c32b6..d75a7b6d 100644 --- a/packages/core/src/context-federation/session-context.ts +++ b/packages/core/src/context-federation/session-context.ts @@ -1,7 +1,7 @@ export * as SessionContext from "./session-context" import { randomBytes } from "node:crypto" -import { and, asc, desc, eq, inArray, max } from "drizzle-orm" +import { and, asc, desc, eq, inArray, max, sql } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { Hash } from "../util/hash" @@ -221,12 +221,7 @@ export type CommitSelectionInput = { readonly rendered: Rendered readonly artifact: Omit< AuditArtifact, - | "schemaVersion" - | "selectionId" - | "queryFingerprint" - | "authorizationFingerprint" - | "graphStatuses" - | "selected" + "schemaVersion" | "selectionId" | "queryFingerprint" | "authorizationFingerprint" | "graphStatuses" | "selected" > readonly now?: number } @@ -266,6 +261,16 @@ export const layer = Layer.effect( if (!admitted) return yield* new InputError({ reason: "missing" }) if (admitted.session_id !== input.sessionId) return yield* new InputError({ reason: "wrong_session" }) if (admitted.promoted_seq === null) return yield* new InputError({ reason: "not_promoted" }) + yield* tx.run(sql` + INSERT INTO session_activity_admission ( + admission_id, session_id, source_kind, session_input_id, admitted_message_id, + delivery, payload_fingerprint_kind, payload_fingerprint, created_at + ) VALUES ( + ${`v2:${admitted.id}`}, ${admitted.session_id}, 'session_input', ${admitted.id}, ${admitted.id}, + ${admitted.delivery}, 'payload_hash', ${Hash.sha256(JSON.stringify(admitted.prompt))}, ${admitted.time_created} + ) + ON CONFLICT(session_input_id) DO NOTHING + `) const owned = yield* tx .select({ activity_id: SessionActivityInputTable.activity_id }) .from(SessionActivityInputTable) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index ebd248f1..036b2a52 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -78,5 +78,7 @@ export const migrations = ( import("./migration/20260810150000_provider_receipt_authority"), import("./migration/20260810160000_compaction_continuation_admission"), import("./migration/20260810170000_part_integrity_backfill"), + import("./migration/20260811090000_legacy_activity_progress"), + import("./migration/20260811100000_legacy_activity_owner"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260811090000_legacy_activity_progress.ts b/packages/core/src/database/migration/20260811090000_legacy_activity_progress.ts new file mode 100644 index 00000000..16a601e9 --- /dev/null +++ b/packages/core/src/database/migration/20260811090000_legacy_activity_progress.ts @@ -0,0 +1,207 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260811090000_legacy_activity_progress", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE session_activity_admission ( + admission_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + source_kind TEXT NOT NULL CHECK (source_kind IN ('legacy_intent', 'session_input')), + legacy_intent_id TEXT UNIQUE REFERENCES session_intent(intent_id), + session_input_id TEXT UNIQUE REFERENCES session_input(id), + admitted_message_id TEXT NOT NULL, + delivery TEXT NOT NULL CHECK (delivery IN ('turn', 'steer', 'queue', 'goal_steer')), + payload_fingerprint_kind TEXT NOT NULL CHECK (payload_fingerprint_kind IN ('payload_hash', 'source_identity')), + payload_fingerprint TEXT NOT NULL, + created_at INTEGER NOT NULL, + CHECK ( + (source_kind = 'legacy_intent' AND legacy_intent_id IS NOT NULL AND session_input_id IS NULL) OR + (source_kind = 'session_input' AND session_input_id IS NOT NULL AND legacy_intent_id IS NULL) + ) + ) + `) + yield* tx.run(` + CREATE TRIGGER session_activity_admission_validate_insert + BEFORE INSERT ON session_activity_admission + BEGIN + SELECT CASE WHEN NEW.source_kind = 'legacy_intent' AND NOT EXISTS ( + SELECT 1 FROM session_intent + WHERE intent_id = NEW.legacy_intent_id + AND session_id = NEW.session_id + AND admitted_message_id = NEW.admitted_message_id + AND delivery = NEW.delivery + AND NEW.payload_fingerprint_kind = 'payload_hash' + AND selected_payload_hash = NEW.payload_fingerprint + AND state = 'admitted' + ) THEN RAISE(ABORT, 'legacy activity admission does not match admitted intent') END; + SELECT CASE WHEN NEW.source_kind = 'session_input' AND NOT EXISTS ( + SELECT 1 FROM session_input + WHERE id = NEW.session_input_id + AND session_id = NEW.session_id + AND id = NEW.admitted_message_id + AND delivery = NEW.delivery + ) THEN RAISE(ABORT, 'v2 activity admission does not match session input') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_activity_admission_immutable + BEFORE UPDATE ON session_activity_admission + BEGIN + SELECT RAISE(ABORT, 'session_activity_admission is immutable'); + END + `) + yield* tx.run(` + INSERT INTO session_activity_admission ( + admission_id, session_id, source_kind, session_input_id, admitted_message_id, + delivery, payload_fingerprint_kind, payload_fingerprint, created_at + ) + SELECT + 'v2:' || input.id, + input.session_id, + 'session_input', + input.id, + input.id, + input.delivery, + 'source_identity', + 'session-input:' || input.id, + input.time_created + FROM session_input input + WHERE EXISTS ( + SELECT 1 FROM session_activity activity WHERE activity.trigger_input_id = input.id + ) + `) + yield* tx.run(` + CREATE TABLE session_legacy_activity ( + activity_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + trigger_admission_id TEXT NOT NULL UNIQUE REFERENCES session_activity_admission(admission_id), + state TEXT NOT NULL CHECK (state IN ('active', 'settled', 'failed', 'interrupted', 'recovery_required')), + terminal_reason TEXT, + created_at INTEGER NOT NULL, + settled_at INTEGER, + UNIQUE (session_id, ordinal), + CHECK ( + (state = 'active' AND settled_at IS NULL AND terminal_reason IS NULL) OR + (state != 'active' AND settled_at IS NOT NULL AND terminal_reason IS NOT NULL) + ) + ) + `) + yield* tx.run(` + CREATE UNIQUE INDEX session_legacy_activity_active_idx + ON session_legacy_activity(session_id) + WHERE state = 'active' + `) + yield* tx.run(` + CREATE TABLE session_legacy_activity_admission ( + activity_id TEXT NOT NULL REFERENCES session_legacy_activity(activity_id) ON DELETE CASCADE, + admission_id TEXT NOT NULL UNIQUE REFERENCES session_activity_admission(admission_id), + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + role TEXT NOT NULL CHECK (role IN ('trigger', 'steer')), + attached_at INTEGER NOT NULL, + PRIMARY KEY (activity_id, admission_id), + UNIQUE (activity_id, ordinal), + CHECK ((role = 'trigger' AND ordinal = 0) OR (role = 'steer' AND ordinal > 0)) + ) + `) + yield* tx.run(` + CREATE TRIGGER session_legacy_activity_admission_validate_insert + BEFORE INSERT ON session_legacy_activity_admission + BEGIN + SELECT CASE WHEN NEW.role = 'trigger' AND NOT EXISTS ( + SELECT 1 FROM session_legacy_activity activity + WHERE activity.activity_id = NEW.activity_id + AND activity.trigger_admission_id = NEW.admission_id + ) THEN RAISE(ABORT, 'legacy activity trigger admission mismatch') END; + SELECT CASE WHEN NEW.role = 'steer' AND NOT EXISTS ( + SELECT 1 + FROM session_legacy_activity activity + JOIN session_activity_admission admission ON admission.admission_id = NEW.admission_id + WHERE activity.activity_id = NEW.activity_id + AND activity.state = 'active' + AND admission.session_id = activity.session_id + AND admission.source_kind = 'legacy_intent' + AND admission.delivery = 'steer' + ) THEN RAISE(ABORT, 'legacy steer admission is not owned by active activity') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_legacy_activity_admission_immutable + BEFORE UPDATE ON session_legacy_activity_admission + BEGIN + SELECT RAISE(ABORT, 'session_legacy_activity_admission is immutable'); + END + `) + yield* tx.run(` + CREATE TRIGGER session_legacy_activity_legal_update + BEFORE UPDATE ON session_legacy_activity + WHEN NEW.activity_id != OLD.activity_id + OR NEW.session_id != OLD.session_id + OR NEW.ordinal != OLD.ordinal + OR NEW.trigger_admission_id != OLD.trigger_admission_id + OR NEW.created_at != OLD.created_at + OR OLD.state != 'active' + OR NEW.state NOT IN ('settled', 'failed', 'interrupted', 'recovery_required') + OR NEW.settled_at IS NULL + OR NEW.terminal_reason IS NULL + BEGIN + SELECT RAISE(ABORT, 'illegal session_legacy_activity transition'); + END + `) + yield* tx.run(` + CREATE TABLE session_activity_progress ( + activity_id TEXT NOT NULL REFERENCES session_legacy_activity(activity_id) ON DELETE CASCADE, + revision INTEGER NOT NULL CHECK (revision >= 0), + assistant_message_id TEXT NOT NULL UNIQUE REFERENCES message(id) ON DELETE CASCADE, + text_part_id TEXT REFERENCES part(id) ON DELETE SET NULL, + provider_receipt_id TEXT NOT NULL UNIQUE REFERENCES session_tool_request_receipt(receipt_id), + state TEXT NOT NULL CHECK (state IN ('provisional', 'progress', 'final', 'interrupted', 'recovery_required')), + finish_observed TEXT, + response_fingerprint TEXT, + created_at INTEGER NOT NULL, + settled_at INTEGER, + PRIMARY KEY (activity_id, revision), + CHECK ( + (state = 'provisional' AND settled_at IS NULL AND response_fingerprint IS NULL) OR + (state != 'provisional' AND settled_at IS NOT NULL) + ) + ) + `) + yield* tx.run(` + CREATE TRIGGER session_activity_progress_validate_insert + BEFORE INSERT ON session_activity_progress + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 + FROM session_legacy_activity activity + JOIN message assistant ON assistant.id = NEW.assistant_message_id + JOIN session_tool_request_receipt receipt ON receipt.receipt_id = NEW.provider_receipt_id + WHERE activity.activity_id = NEW.activity_id + AND activity.state = 'active' + AND assistant.session_id = activity.session_id + AND receipt.session_id = activity.session_id + AND receipt.assistant_message_id = NEW.assistant_message_id + ) THEN RAISE(ABORT, 'activity progress ownership mismatch') END; + END + `) + yield* tx.run(` + CREATE TRIGGER session_activity_progress_legal_update + BEFORE UPDATE ON session_activity_progress + WHEN NEW.activity_id != OLD.activity_id + OR NEW.revision != OLD.revision + OR NEW.assistant_message_id != OLD.assistant_message_id + OR NEW.provider_receipt_id != OLD.provider_receipt_id + OR NEW.created_at != OLD.created_at + OR OLD.state != 'provisional' + OR NEW.state NOT IN ('progress', 'final', 'interrupted', 'recovery_required') + OR NEW.settled_at IS NULL + BEGIN + SELECT RAISE(ABORT, 'illegal session_activity_progress transition'); + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/migration/20260811100000_legacy_activity_owner.ts b/packages/core/src/database/migration/20260811100000_legacy_activity_owner.ts new file mode 100644 index 00000000..d5c8b7b8 --- /dev/null +++ b/packages/core/src/database/migration/20260811100000_legacy_activity_owner.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260811100000_legacy_activity_owner", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + ALTER TABLE session_legacy_activity + ADD COLUMN owner_token TEXT NOT NULL DEFAULT 'pre-owner-migration' + `) + yield* tx.run(`DROP TRIGGER session_legacy_activity_legal_update`) + yield* tx.run(` + CREATE TRIGGER session_legacy_activity_legal_update + BEFORE UPDATE ON session_legacy_activity + WHEN NEW.activity_id != OLD.activity_id + OR NEW.session_id != OLD.session_id + OR NEW.ordinal != OLD.ordinal + OR NEW.trigger_admission_id != OLD.trigger_admission_id + OR NEW.owner_token != OLD.owner_token + OR NEW.created_at != OLD.created_at + OR OLD.state != 'active' + OR NEW.state NOT IN ('settled', 'failed', 'interrupted', 'recovery_required') + OR NEW.settled_at IS NULL + OR NEW.terminal_reason IS NULL + BEGIN + SELECT RAISE(ABORT, 'illegal session_legacy_activity transition'); + END + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index e410647a..171bfbe9 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -26,6 +26,8 @@ import { SessionTable } from "@deepagent-code/core/session/sql" import sessionMetadataMigration from "@deepagent-code/core/database/migration/20260511173437_session-metadata" import compactionContinuationAdmissionMigration from "@deepagent-code/core/database/migration/20260810160000_compaction_continuation_admission" import partIntegrityBackfillMigration from "@deepagent-code/core/database/migration/20260810170000_part_integrity_backfill" +import legacyActivityProgressMigration from "@deepagent-code/core/database/migration/20260811090000_legacy_activity_progress" +import legacyActivityOwnerMigration from "@deepagent-code/core/database/migration/20260811100000_legacy_activity_owner" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@deepagent-code/core/database/database" import { tmpdir } from "./fixture/tmpdir" @@ -197,6 +199,100 @@ describe("DatabaseMigration", () => { ) }) + test("reapplying tracked migrations is a no-op", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* DatabaseMigration.apply(db) + const before = yield* db.get(sql`SELECT count(*) as count FROM migration`) + yield* DatabaseMigration.apply(db) + expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual(before) + }), + ) + }) + + test("legacy activity migration backfills V2 source identity and rejects mismatched legacy admissions", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* db.run(sql`CREATE TABLE session (id TEXT PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE session_intent ( + intent_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, admitted_message_id TEXT, + delivery TEXT, selected_payload_hash TEXT, state TEXT NOT NULL + )`) + yield* db.run(sql`CREATE TABLE session_input ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, prompt TEXT NOT NULL, + delivery TEXT NOT NULL, admitted_seq INTEGER NOT NULL, promoted_seq INTEGER, + time_created INTEGER NOT NULL + )`) + yield* db.run(sql`CREATE TABLE session_activity ( + activity_id TEXT PRIMARY KEY, trigger_input_id TEXT NOT NULL + )`) + yield* db.run(sql`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, data TEXT NOT NULL)`) + yield* db.run(sql`CREATE TABLE part (id TEXT PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE session_tool_request_receipt (receipt_id TEXT PRIMARY KEY)`) + yield* db.run(sql`INSERT INTO session VALUES ('ses_migration')`) + yield* db.run(sql`INSERT INTO session_input VALUES + ('input-v2', 'ses_migration', '{"text":"v2"}', 'turn', 1, 7, 11)`) + yield* db.run(sql`INSERT INTO session_activity VALUES ('activity-v2', 'input-v2')`) + + yield* DatabaseMigration.applyOnly(db, [legacyActivityProgressMigration]) + expect( + yield* db.get(sql` + SELECT source_kind, payload_fingerprint_kind, payload_fingerprint + FROM session_activity_admission WHERE admission_id = 'v2:input-v2' + `), + ).toEqual({ + source_kind: "session_input", + payload_fingerprint_kind: "source_identity", + payload_fingerprint: "session-input:input-v2", + }) + + yield* db.run(sql`INSERT INTO session_intent VALUES + ('legacy-intent', 'ses_migration', 'legacy-message', 'turn', 'payload-hash', 'admitted')`) + yield* db.run(sql` + INSERT INTO session_activity_admission ( + admission_id, session_id, source_kind, legacy_intent_id, admitted_message_id, + delivery, payload_fingerprint_kind, payload_fingerprint, created_at + ) VALUES ('legacy-admission', 'ses_migration', 'legacy_intent', 'legacy-intent', + 'legacy-message', 'turn', 'payload_hash', 'payload-hash', 12) + `) + yield* db.run(sql`INSERT INTO session_legacy_activity VALUES + ('legacy-activity', 'ses_migration', 0, 'legacy-admission', 'active', NULL, 12, NULL)`) + yield* db.run(sql`INSERT INTO session_legacy_activity_admission + (activity_id, admission_id, ordinal, role, attached_at) + VALUES ('legacy-activity', 'legacy-admission', 0, 'trigger', 12)`) + yield* DatabaseMigration.applyOnly(db, [legacyActivityOwnerMigration]) + expect( + yield* db.get(sql`SELECT owner_token FROM session_legacy_activity WHERE activity_id = 'legacy-activity'`), + ).toEqual({ owner_token: "pre-owner-migration" }) + expect( + Exit.isFailure( + yield* db + .run(sql`UPDATE session_legacy_activity SET owner_token = 'other' WHERE activity_id = 'legacy-activity'`) + .pipe(Effect.exit), + ), + ).toBe(true) + expect( + Exit.isFailure( + yield* db + .run( + sql` + INSERT INTO session_activity_admission ( + admission_id, session_id, source_kind, legacy_intent_id, admitted_message_id, + delivery, payload_fingerprint_kind, payload_fingerprint, created_at + ) VALUES ('invalid-admission', 'ses_migration', 'legacy_intent', 'legacy-intent', + 'legacy-message', 'turn', 'source_identity', 'session-input:legacy-message', 12) + `, + ) + .pipe(Effect.exit), + ), + ).toBe(true) + }), + ) + }) + test("enforces provider receipt lifecycle and compaction part provenance", async () => { await run( Effect.gen(function* () { diff --git a/packages/deepagent-code/package.json b/packages/deepagent-code/package.json index b325b044..7020f726 100644 --- a/packages/deepagent-code/package.json +++ b/packages/deepagent-code/package.json @@ -10,7 +10,7 @@ "test": "bun test --timeout 30000 --max-concurrency 4", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:llm-routes": "bun test --timeout 30000 test/script/live-llm-routes.test.ts test/script/run-live-llm-all.test.ts", - "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts test/script/live-llm-plan-advance-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", + "test:llm-det:contracts": "bun test --timeout 30000 test/tool/apply_patch_chunk.test.ts test/tool/task.test.ts test/tool/task-concurrency.test.ts test/tool/task-run.test.ts test/tool/registry.test.ts test/tool/truncation.test.ts test/tool/shell.test.ts test/mcp/adapter.test.ts test/mcp/lifecycle.test.ts test/session/structured-output.test.ts test/session/conversation-log-writer.test.ts test/session/tool-input-validation.test.ts test/cli/run/run-process.test.ts test/script/live-llm-eval-scoring.test.ts test/script/live-llm-goal-cli-oracle.test.ts test/script/live-llm-expert-panel-oracle.test.ts test/script/live-llm-plan-advance-oracle.test.ts test/script/live-llm-plan-create-replan-oracle.test.ts test/script/live-llm-activity-progress-oracle.test.ts && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'runs a prompt in the persisted session directory' && bun test --timeout 30000 test/session/prompt.test.ts --test-name-pattern 'World State'", "test:llm-live:cli-headless": "bun run script/live-llm/cli-headless.ts", "test:llm-ext:goal-cli": "bun run script/live-llm/cli-goal-loop.ts", "test:llm-live:structured-legacy": "bun run script/live-llm/structured-output-legacy.ts", @@ -28,8 +28,10 @@ "test:llm-live:continuation-repetition": "bun run script/live-llm/continuation-repetition.ts", "test:llm-live:degeneration": "bun run script/live-llm/degeneration.ts", "test:llm-live:plan-advance": "bun run script/live-llm/plan-advance-contract.ts", + "test:llm-live:plan-create-replan": "bun run script/live-llm/plan-create-replan-contract.ts", "test:llm-ext:finalizer-isolation": "bun run script/live-llm/finalizer-isolation.ts", "test:llm-live:steer-boundary": "bun run script/live-llm/steer-boundary.ts", + "test:llm-live:activity-progress": "bun run script/live-llm/activity-progress-lifecycle.ts", "test:llm-ext:subagent-worktree": "bun run script/live-llm/subagent-worktree.ts", "test:llm-ext:multi-agent-dag": "bun run script/live-llm/multi-agent-dag.ts", "test:llm-ext:multi-agent-parallel-worktrees": "bun run script/live-llm/multi-agent-parallel-worktrees.ts", diff --git a/packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts b/packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts new file mode 100644 index 00000000..dc26fe57 --- /dev/null +++ b/packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts @@ -0,0 +1,114 @@ +import { loadLiveLLMConfig, writeLiveArtifact } from "../../../llm/script/live-llm/config" +import { assertActivityProgressObservation } from "./activity-progress-oracle" +import { finishLiveScript } from "./lifecycle" +import { runLegacyLiveCases } from "./runtime" + +const config = await loadLiveLLMConfig() +if (config.providerID !== "deepseek" || config.modelID !== "deepseek-v4-flash") { + throw new Error("Activity progress release test requires DeepSeek deepseek-v4-flash") +} + +const marker = `activity-progress-${crypto.randomUUID()}` +const facts = Array.from({ length: 3 }, (_, index) => ({ + path: `facts/${index + 1}.txt`, + value: `durable-fact-${index + 1}-${crypto.randomUUID()}`, +})) +const triggerText = [ + `Read ${facts.map((fact) => fact.path).join(", ")} in that exact order.`, + "Issue exactly one read call per assistant turn and wait for each result before reading the next file.", + "After all reads, return the three file values in order in one short final line.", + "Before every action, absorb any newer user direction delivered while this turn is active.", +].join(" ") +const steerText = [ + "Keep the same read sequence and do not repeat any completed read.", + `In the final line append this exact marker once: ${marker}`, +].join(" ") + +const artifact = await runLegacyLiveCases({ + suite: "activity-progress-lifecycle-legacy", + config, + permission: { + "*": "deny", + read: { "*": "deny", ...Object.fromEntries(facts.map((fact) => [fact.path, "allow" as const])) }, + }, + cases: [{ name: "tool-continuation-with-steer", prompt: triggerText }], + files: Object.fromEntries(facts.map((fact) => [fact.path, `${fact.value}\n`])), + steerDuringCases: [{ duringCaseName: "tool-continuation-with-steer", text: steerText }], + primaryPrompt: [ + "This is a durable activity/progress lifecycle test.", + "Use only the read tool explicitly requested by the current user and issue exactly one tool call per assistant turn.", + "At every provider boundary absorb newer steer input, continue unfinished work once, and never repeat a completed tool call.", + "The final answer must contain only the requested fact values and marker, each exactly once.", + ].join(" "), + inspectDurability: true, + observeAssembledRequestFingerprints: true, + modelMaxTokens: 768, + maxProviderTurns: 8, +}) + +await writeLiveArtifact(config, `${artifact.suite}-observed`, artifact, { + redactions: [ + { value: marker, replacement: "" }, + ...facts.map((fact, index) => ({ value: fact.value, replacement: `` })), + ], +}) +if (artifact.status !== "passed") { + throw new Error(`Activity progress Provider run failed: ${JSON.stringify(artifact.error)}`) +} +const observation = artifact.cases[0] +if (!observation) throw new Error("Activity progress suite produced no observation") +const evidence = assertActivityProgressObservation({ + caseName: observation.name, + triggerText, + steerText, + marker, + expectedTools: facts.map(() => "read"), + observation, +}) +facts.forEach((fact, index) => { + if (!observation.finalText.includes(fact.value)) { + throw new Error(`Activity progress final response omitted fact ${index + 1}`) + } +}) +const indexes = [ + ...facts.map((fact) => observation.finalText.indexOf(fact.value)), + observation.finalText.indexOf(marker), +] +if ( + indexes.some((index) => index < 0) || + indexes.some((index, offset) => offset > 0 && index <= indexes[offset - 1]!) +) { + throw new Error(`Activity progress final evidence was missing or out of order: ${indexes.join(", ")}`) +} +if (artifact.workspace.status.trim()) throw new Error("Activity progress read-only suite mutated the workspace") + +const result = { + ...artifact, + evidence: { + provider: config.providerID, + model: config.modelID, + markerHash: Bun.hash(marker).toString(16), + factHashes: facts.map((fact) => Bun.hash(fact.value).toString(16)), + activityIDHash: Bun.hash(evidence.activity.activity_id).toString(16), + activityState: evidence.activity.state, + progressStates: evidence.progress.map((progress) => `${progress.revision}:${progress.state}`), + assistantTurns: observation.assistantTurns, + toolSequence: observation.newTools.map((tool) => `${tool.name}:${tool.status}`), + userMessages: observation.users.length, + }, +} +await writeLiveArtifact(config, result.suite, result, { + redactions: [ + { value: marker, replacement: `` }, + ...facts.map((fact, index) => ({ + value: fact.value, + replacement: ``, + })), + ], +}) +console.log( + `${result.suite}: passed (${result.fingerprint.providerID}/${result.fingerprint.modelID}, ` + + `${result.evidence.assistantTurns} assistant turns, ${result.evidence.progressStates.length} progress revisions)`, +) + +finishLiveScript() diff --git a/packages/deepagent-code/script/live-llm/activity-progress-oracle.ts b/packages/deepagent-code/script/live-llm/activity-progress-oracle.ts new file mode 100644 index 00000000..0ec9f4a7 --- /dev/null +++ b/packages/deepagent-code/script/live-llm/activity-progress-oracle.ts @@ -0,0 +1,186 @@ +type ActivityProgressMarker = { + activity_id: string + revision: number + state: "progress" | "final" | "interrupted" | "recovery_required" +} + +type ActivityDurability = { + activityAdmissions: ReadonlyArray<{ + admission_id: string + delivery: string + admitted_message_id: string + }> + legacyActivities: ReadonlyArray<{ + activity_id: string + owner_token: string + state: string + terminal_reason: string | null + }> + legacyActivityAdmissions: ReadonlyArray<{ + activity_id: string + admission_id: string + ordinal: number + role: string + }> + activityProgress: ReadonlyArray<{ + activity_id: string + revision: number + assistant_message_id: string + provider_receipt_id: string + state: string + }> + activityTextParts: ReadonlyArray<{ + id: string + message_id: string + data: unknown + }> + requestReceipts: ReadonlyArray<{ + receipt_id: string + request_state: string + }> +} + +export function assertActivityProgressObservation(input: { + caseName: string + triggerText: string + steerText: string + marker: string + expectedTools: readonly string[] + observation: { + users: ReadonlyArray<{ text: string }> + steering: ReadonlyArray<{ + delivery: string + activeBeforeAdmission: boolean + pendingAfterAdmission: boolean + consumedAfterAdmission: boolean + }> + assistantTurns: number + finalText: string + newTools: ReadonlyArray<{ name: string; status: string }> + providerErrors: readonly unknown[] + durability?: ActivityDurability + } +}) { + if (input.observation.providerErrors.length > 0) { + throw new Error(`${input.caseName} recorded Provider errors`) + } + if ( + input.observation.users.length !== 2 || + input.observation.users.filter((user) => user.text === input.triggerText).length !== 1 || + input.observation.users.filter((user) => user.text === input.steerText).length !== 1 + ) { + throw new Error(`${input.caseName} did not materialize trigger and steer exactly once`) + } + const steering = input.observation.steering[0] + if ( + input.observation.steering.length !== 1 || + !steering || + steering.delivery !== "steer" || + !steering.activeBeforeAdmission || + !steering.pendingAfterAdmission || + !steering.consumedAfterAdmission + ) { + throw new Error(`${input.caseName} did not durably absorb one active-turn steer`) + } + if ( + input.observation.newTools.length !== input.expectedTools.length || + input.observation.newTools.some( + (tool, index) => tool.name !== input.expectedTools[index] || tool.status !== "completed", + ) + ) { + throw new Error(`${input.caseName} tool sequence did not complete as requested`) + } + if (input.observation.finalText.split(input.marker).length !== 2) { + throw new Error(`${input.caseName} final response did not contain the marker exactly once`) + } + + const durability = input.observation.durability + if (!durability) throw new Error(`${input.caseName} did not capture activity durability`) + if (durability.activityAdmissions.length !== 2) { + throw new Error(`${input.caseName} expected two activity admissions`) + } + const trigger = durability.activityAdmissions.find((admission) => admission.delivery === "turn") + const steer = durability.activityAdmissions.find((admission) => admission.delivery === "steer") + if (!trigger || !steer) throw new Error(`${input.caseName} did not persist trigger and steer admissions`) + + const activity = durability.legacyActivities[0] + if ( + durability.legacyActivities.length !== 1 || + !activity || + activity.state !== "settled" || + activity.terminal_reason !== "stop" || + activity.owner_token.length === 0 || + activity.owner_token === "pre-owner-migration" + ) { + throw new Error(`${input.caseName} did not settle one process-owned activity`) + } + const memberships = [...durability.legacyActivityAdmissions].sort((left, right) => left.ordinal - right.ordinal) + if ( + memberships.length !== 2 || + memberships[0]?.activity_id !== activity.activity_id || + memberships[0]?.admission_id !== trigger.admission_id || + memberships[0]?.ordinal !== 0 || + memberships[0]?.role !== "trigger" || + memberships[1]?.activity_id !== activity.activity_id || + memberships[1]?.admission_id !== steer.admission_id || + memberships[1]?.ordinal !== 1 || + memberships[1]?.role !== "steer" + ) { + throw new Error(`${input.caseName} activity membership was not trigger plus steer in durable order`) + } + + const progress = [...durability.activityProgress].sort((left, right) => left.revision - right.revision) + if ( + progress.length < 2 || + progress.some((item, index) => item.activity_id !== activity.activity_id || item.revision !== index) || + progress.slice(0, -1).some((item) => item.state !== "progress") || + progress.at(-1)?.state !== "final" + ) { + throw new Error(`${input.caseName} activity progress was not contiguous progress-to-final`) + } + const receiptIDs = new Set( + durability.requestReceipts + .filter((receipt) => receipt.request_state === "dispatched") + .map((receipt) => receipt.receipt_id), + ) + if (progress.some((item) => !receiptIDs.has(item.provider_receipt_id))) { + throw new Error(`${input.caseName} progress row lacked a dispatched provider receipt`) + } + progress.forEach((item) => { + const parts = durability.activityTextParts.filter((part) => part.message_id === item.assistant_message_id) + parts.forEach((part) => { + const marker = activityMarker(part.data) + if ( + !marker || + marker.activity_id !== activity.activity_id || + marker.revision !== item.revision || + marker.state !== item.state + ) { + throw new Error(`${input.caseName} text part ${part.id} lacked the durable progress marker`) + } + }) + }) + if (input.observation.assistantTurns !== progress.length) { + throw new Error(`${input.caseName} assistant turns and progress revisions diverged`) + } + return { activity, progress } +} + +function activityMarker(data: unknown): ActivityProgressMarker | undefined { + const part = record(data) + const metadata = record(part?.metadata) + const marker = record(metadata?.deepagent_activity_progress) + if ( + typeof marker?.activity_id !== "string" || + typeof marker.revision !== "number" || + !["progress", "final", "interrupted", "recovery_required"].includes(String(marker.state)) + ) { + return + } + return marker as ActivityProgressMarker +} + +function record(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return + return value as Record +} diff --git a/packages/deepagent-code/script/live-llm/dispatcher.ts b/packages/deepagent-code/script/live-llm/dispatcher.ts index d19bbd65..5f7e6cbe 100644 --- a/packages/deepagent-code/script/live-llm/dispatcher.ts +++ b/packages/deepagent-code/script/live-llm/dispatcher.ts @@ -199,6 +199,10 @@ const modelCommands = new Map([ "live:legacy-session:plan-advance-contract", command("packages/deepagent-code", "bun", "run", "test:llm-live:plan-advance"), ], + [ + "live:legacy-session:plan-create-replan-contract", + command("packages/deepagent-code", "bun", "run", "test:llm-live:plan-create-replan"), + ], [ "ext:legacy-session:subagent-finalizer-isolation", command("packages/deepagent-code", "bun", "run", "test:llm-ext:finalizer-isolation"), @@ -207,6 +211,18 @@ const modelCommands = new Map([ "live:legacy-session:steer-boundary", command("packages/deepagent-code", "bun", "run", "test:llm-live:steer-boundary"), ], + [ + "live:legacy-session:activity-progress-lifecycle", + command("packages/deepagent-code", "bun", "run", "test:llm-live:activity-progress"), + ], + [ + "live:packaged-sidecar:activity-progress-restart", + command("packages/desktop", "bun", "run", "test:llm-live:activity-progress-restart"), + ], + [ + "ext:renderer-ui:activity-progress-package", + command("packages/desktop", "bun", "run", "test:llm-release:activity-progress-package"), + ], [ "ext:legacy-session:subagent-worktree-routing", command("packages/deepagent-code", "bun", "run", "test:llm-ext:subagent-worktree"), diff --git a/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts index c249b82b..73c4ca9c 100644 --- a/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts +++ b/packages/deepagent-code/script/live-llm/plan-advance-oracle.ts @@ -1,4 +1,4 @@ -type PlanStep = { +export type PlanOracleStep = { step_id: string title: string status: string @@ -7,15 +7,15 @@ type PlanStep = { note?: string | null } -type PlanDocument = { +export type PlanOracleDocument = { plan_id: string goal: string assumptions: readonly string[] active_step_id: string | null - steps: readonly PlanStep[] + steps: readonly PlanOracleStep[] } -type ToolCall = { +export type PlanToolCall = { messageID: string id: string name: string @@ -24,7 +24,7 @@ type ToolCall = { metadata?: unknown } -type RequestReceipt = { +export type PlanRequestReceipt = { receipt_id: string assistant_message_id: string | null request_state: string @@ -33,7 +33,7 @@ type RequestReceipt = { tool_definition_hash: string | null } -type ArgumentReceipt = { +export type PlanArgumentReceipt = { receipt_id: string layer: string call_id: string | null @@ -49,14 +49,14 @@ type ArgumentReceipt = { export function assertPlanAdvanceObservation(input: { caseName: string observation: { - newTools: readonly ToolCall[] - plan?: { document: PlanDocument | null; ref: { id: string; version: number } | null } + newTools: readonly PlanToolCall[] + plan?: { document: PlanOracleDocument | null; ref: { id: string; version: number } | null } durability?: { - requestReceipts: readonly RequestReceipt[] - argumentReceipts: readonly ArgumentReceipt[] + requestReceipts: readonly PlanRequestReceipt[] + argumentReceipts: readonly PlanArgumentReceipt[] } } - immutable: PlanDocument + immutable: PlanOracleDocument expectedVersion: number expectedActiveStepID: string | null expectedStatuses: Readonly> @@ -119,7 +119,7 @@ export function assertPlanAdvanceObservation(input: { `${input.caseName} plan call ${index + 1} expected ${expected.protocol}, received ${String(metadata.plan_protocol)}`, ) } - assertArgumentReceipts(input.caseName, call, input.observation.durability, expected.protocol) + assertPlanArgumentReceipts(input.caseName, call, input.observation.durability, expected.protocol) }) const plan = input.observation.plan?.document @@ -159,13 +159,13 @@ export function assertPlanAdvanceObservation(input: { }) } -function assertArgumentReceipts( +export function assertPlanArgumentReceipts( caseName: string, - call: ToolCall, + call: PlanToolCall, durability: | { - requestReceipts: readonly RequestReceipt[] - argumentReceipts: readonly ArgumentReceipt[] + requestReceipts: readonly PlanRequestReceipt[] + argumentReceipts: readonly PlanArgumentReceipt[] } | undefined, protocol: "success" | "conflict", diff --git a/packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts b/packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts new file mode 100644 index 00000000..e6a7fdaf --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts @@ -0,0 +1,224 @@ +import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import { loadLiveLLMConfig, writeLiveArtifact } from "../../../llm/script/live-llm/config" +import { finishLiveScript } from "./lifecycle" +import { assertPlanCreateObservation, assertPlanReplanObservation } from "./plan-create-replan-oracle" +import { runLegacyLiveCases } from "./runtime" + +const config = await loadLiveLLMConfig() +if (config.providerID !== "deepseek" || config.modelID !== "deepseek-v4-flash") { + throw new Error("Plan create/replan release test requires DeepSeek deepseek-v4-flash") +} + +const goal = "Prove create and replan preserve server-owned Plan authority" +const assumptions = ["server_assigns_every_new_step_id", "retained_identity_is_authoritative"] +const titles = { + inspect: "Inspect the authoritative Plan contract", + replan: "Replan without guessing hidden identity", + verify: "Verify server allocated the new step ID", + release: "Record the release evidence", +} +const reasons = { + addVerification: "Add an explicit verification step after inspecting the contract", + addReleaseEvidence: "Add the final release-evidence step after verification begins", +} +const conflictCase = "replan-after-authority-race" +const concurrentNote = "concurrent authority note retained" +let conflictInjected = false + +const artifact = await runLegacyLiveCases({ + suite: "plan-create-replan-contract-legacy", + config, + permission: { "*": "deny" }, + primaryPermission: { "*": "deny", plan: "ask" }, + permissionReply: { reply: "once" }, + sharedSession: true, + inspectDurability: true, + inspectPlan: true, + observeAssembledRequestFingerprints: true, + environment: { DEEPAGENT_ENABLED: "true", DEEPAGENT_MODE: "high" }, + primaryPrompt: [ + "This is a Plan create/replan parameter-contract test in one durable Session.", + "Call only the plan tool requested by the current user.", + "For create, expected_plan_id and expected_version are null; omit every step_id and omit active_step_id.", + "For replan, copy the visible plan ID, version, and retained step IDs exactly.", + "Omit step_id for every new replan step and omit active_step_id so the server derives it.", + "For retained steps, do not guess title, acceptance, assigned_agent, or note; omit them unless an authoritative correction explicitly supplies them.", + "Omit assumptions on replan so the server retains the authoritative list.", + "If a plan conflict occurs, retry the requested replan exactly once using the authoritative correction.", + ].join(" "), + modelMaxTokens: 1024, + maxProviderTurns: 5, + cases: [ + { + name: "create-server-owned-ids", + prompt: [ + "Call plan exactly once with operation create.", + `Set goal exactly to: ${goal}`, + `Set assumptions to the exact JSON array ${JSON.stringify(assumptions)} without changing any character.`, + `Create exactly two steps in order: ${titles.inspect} with status active; ${titles.replan} with status pending.`, + "Each step object must contain only title and status.", + "Set expected_plan_id and expected_version to null. Omit active_step_id and omit every step_id.", + "Do not call another tool.", + ].join(" "), + }, + { + name: "replan-retain-and-allocate", + prompt: replanPrompt({ + reason: reasons.addVerification, + statuses: [ + [titles.inspect, "done"], + [titles.replan, "active"], + ], + newTitle: titles.verify, + conflict: false, + }), + }, + { + name: conflictCase, + prompt: replanPrompt({ + reason: reasons.addReleaseEvidence, + statuses: [ + [titles.inspect, "done"], + [titles.replan, "done"], + [titles.verify, "active"], + ], + newTitle: titles.release, + conflict: true, + }), + }, + ], + beforePermissionReply: async ({ caseName, request }) => { + if (caseName !== conflictCase || request.permission !== "plan" || conflictInjected) return + const current = AgentGateway.DeepAgentPlanStore.getPlanDoc(request.sessionID) + const ref = AgentGateway.DeepAgentPlanStore.planDocRef(request.sessionID) + if (!current || !ref) throw new Error("Create/replan conflict injection could not read Plan authority") + const committed = AgentGateway.DeepAgentPlanStore.compareAndCommitPlan({ + sessionId: request.sessionID, + expected: { plan_id: current.plan_id, doc_id: ref.id, version: ref.version }, + candidate: { + ...current, + steps: current.steps.map((step) => (step.title === titles.verify ? { ...step, note: concurrentNote } : step)), + }, + origin: "runtime_goal_bridge", + }) + AgentGateway.DeepAgentSessionState.bindPlan(request.sessionID, committed.plan, current, committed.changed) + conflictInjected = true + }, +}) + +await writeLiveArtifact(config, `${artifact.suite}-observed`, artifact) +if (artifact.status !== "passed") { + throw new Error(`Plan create/replan Provider run failed: ${JSON.stringify(artifact.error)}`) +} +if (new Set(artifact.cases.map((testCase) => testCase.sessionID)).size !== 1) { + throw new Error("Plan create/replan cases did not reuse one durable Session") +} + +const create = requireCase("create-server-owned-ids") +const created = assertPlanCreateObservation({ + caseName: create.name, + observation: create, + goal, + assumptions, + steps: [ + { title: titles.inspect, status: "active" }, + { title: titles.replan, status: "pending" }, + ], +}) +const replan = requireCase("replan-retain-and-allocate") +const replanned = assertPlanReplanObservation({ + caseName: replan.name, + observation: replan, + authority: created, + expectedVersion: 2, + expectedActiveTitle: titles.replan, + expectedReason: reasons.addVerification, + expectedStatuses: { + [titles.inspect]: "done", + [titles.replan]: "active", + [titles.verify]: "pending", + }, + expectedNewTitles: [titles.verify], + expectedCalls: [{ version: 1, protocol: "success" }], +}) +const conflicted = requireCase(conflictCase) +const finalPlan = assertPlanReplanObservation({ + caseName: conflicted.name, + observation: conflicted, + authority: replanned, + expectedVersion: 4, + expectedActiveTitle: titles.verify, + expectedReason: reasons.addReleaseEvidence, + expectedStatuses: { + [titles.inspect]: "done", + [titles.replan]: "done", + [titles.verify]: "active", + [titles.release]: "pending", + }, + expectedNewTitles: [titles.release], + expectedCalls: [ + { version: 2, protocol: "conflict" }, + { version: 3, protocol: "success", notes: { [titles.verify]: concurrentNote } }, + ], + expectedNotes: { [titles.verify]: concurrentNote }, +}) +if (!conflictInjected) throw new Error("Plan create/replan suite did not inject the authority race") +if (artifact.cases.some((testCase) => testCase.providerErrors.length > 0)) { + throw new Error("Plan create/replan suite recorded Provider errors") +} + +const result = { + ...artifact, + evidence: { + provider: config.providerID, + model: config.modelID, + conflictInjected, + planIDHash: Bun.hash(finalPlan.plan_id).toString(16), + allocatedStepIDHashes: finalPlan.steps.map((step) => Bun.hash(step.step_id).toString(16)), + finalPlanVersion: conflicted.plan?.ref?.version, + planCalls: artifact.cases.flatMap((testCase) => + testCase.newTools.map((tool) => ({ + caseName: testCase.name, + status: tool.status, + protocol: + typeof tool.metadata === "object" && tool.metadata !== null && "plan_protocol" in tool.metadata + ? tool.metadata.plan_protocol + : undefined, + })), + ), + }, +} +await writeLiveArtifact(config, result.suite, result) +console.log( + `${result.suite}: passed (${result.fingerprint.providerID}/${result.fingerprint.modelID}, ` + + `${result.evidence.planCalls.length} Plan calls, final version ${result.evidence.finalPlanVersion})`, +) + +finishLiveScript() + +function replanPrompt(input: { + reason: string + statuses: ReadonlyArray + newTitle: string + conflict: boolean +}) { + return [ + "Call plan with operation replan.", + "Copy expected_plan_id, expected_version, and every retained step_id exactly from the latest plan-status or authoritative plan result.", + `Copy the current goal exactly and set replan_reason exactly to: ${input.reason}`, + `Retain the existing steps in their current order with these statuses: ${input.statuses.map(([title, status]) => `${title}=${status}`).join("; ")}.`, + `Append exactly one new step titled ${input.newTitle} with status pending.`, + "Each retained step must contain only step_id and status. The new step must contain only title and status.", + "Omit active_step_id. Omit assumptions. Omit title, acceptance, assigned_agent, and note for retained steps.", + input.conflict + ? "If the first result is a Plan conflict, retry the same requested replan exactly once using the authoritative correction and preserve any concurrent note." + : "Call plan exactly once. No conflict is expected.", + "Do not call another tool.", + ].join(" ") +} + +function requireCase(name: string) { + const testCase = artifact.cases.find((item) => item.name === name) + if (!testCase) throw new Error(`Missing Plan create/replan case ${name}`) + return testCase +} diff --git a/packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts b/packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts new file mode 100644 index 00000000..56b3c185 --- /dev/null +++ b/packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts @@ -0,0 +1,227 @@ +import { + assertPlanArgumentReceipts, + type PlanArgumentReceipt, + type PlanOracleDocument, + type PlanRequestReceipt, + type PlanToolCall, +} from "./plan-advance-oracle" + +type PlanObservation = { + newTools: readonly PlanToolCall[] + plan?: { document: PlanOracleDocument | null; ref: { id: string; version: number } | null } + durability?: { + requestReceipts: readonly PlanRequestReceipt[] + argumentReceipts: readonly PlanArgumentReceipt[] + } +} + +export function assertPlanCreateObservation(input: { + caseName: string + observation: PlanObservation + goal: string + assumptions: readonly string[] + steps: ReadonlyArray<{ title: string; status: string }> +}) { + const call = requirePlanCalls(input.caseName, input.observation, 1)[0]! + const args = record(call.input, `${input.caseName} plan input`) + const metadata = record(call.metadata, `${input.caseName} plan metadata`) + assertKeys( + input.caseName, + args, + new Set(["operation", "expected_plan_id", "expected_version", "goal", "assumptions", "steps"]), + ) + if ( + args.operation !== "create" || + args.expected_plan_id !== null || + args.expected_version !== null || + args.goal !== input.goal || + JSON.stringify(args.assumptions) !== JSON.stringify(input.assumptions) + ) { + throw new Error(`${input.caseName} create parameters were not authoritative: ${JSON.stringify(args)}`) + } + const steps = array(args.steps, `${input.caseName} create steps`).map((step, index) => { + const value = record(step, `${input.caseName} create step ${index + 1}`) + assertKeys(input.caseName, value, new Set(["title", "status"])) + if ("step_id" in value) throw new Error(`${input.caseName} create supplied a model-owned step_id`) + return value + }) + if ( + steps.length !== input.steps.length || + steps.some((step, index) => step.title !== input.steps[index]?.title || step.status !== input.steps[index]?.status) + ) { + throw new Error(`${input.caseName} create steps differed from the requested structure`) + } + if (metadata.plan_protocol !== "success") throw new Error(`${input.caseName} create did not succeed`) + assertPlanArgumentReceipts(input.caseName, call, input.observation.durability, "success") + + const plan = input.observation.plan?.document + const ref = input.observation.plan?.ref + if (!plan || !ref || ref.version !== 1) throw new Error(`${input.caseName} did not commit Plan version 1`) + if ( + plan.goal !== input.goal || + JSON.stringify(plan.assumptions) !== JSON.stringify(input.assumptions) || + plan.steps.length !== input.steps.length + ) { + throw new Error(`${input.caseName} committed the wrong Plan structure`) + } + const allocated = new Set(plan.steps.map((step) => step.step_id)) + if (allocated.size !== plan.steps.length || [...allocated].some((id) => id.length === 0)) { + throw new Error(`${input.caseName} did not allocate unique server step IDs`) + } + plan.steps.forEach((step, index) => { + if (step.title !== input.steps[index]?.title || step.status !== input.steps[index]?.status) { + throw new Error(`${input.caseName} committed an unexpected step at index ${index}`) + } + }) + const active = plan.steps.find((step) => step.status === "active") + if (!active || plan.active_step_id !== active.step_id) { + throw new Error(`${input.caseName} did not derive active_step_id from the allocated active step`) + } + return plan +} + +export function assertPlanReplanObservation(input: { + caseName: string + observation: PlanObservation + authority: PlanOracleDocument + expectedVersion: number + expectedActiveTitle: string + expectedReason: string + expectedStatuses: Readonly> + expectedNewTitles: readonly string[] + expectedCalls: ReadonlyArray<{ + version: number + protocol: "success" | "conflict" + notes?: Readonly> + }> + expectedNotes?: Readonly> +}) { + const calls = requirePlanCalls(input.caseName, input.observation, input.expectedCalls.length) + calls.forEach((call, index) => { + const expected = input.expectedCalls[index]! + const args = record(call.input, `${input.caseName} replan input ${index + 1}`) + const metadata = record(call.metadata, `${input.caseName} replan metadata ${index + 1}`) + assertKeys( + input.caseName, + args, + new Set(["operation", "expected_plan_id", "expected_version", "replan_reason", "goal", "steps"]), + ) + if ( + args.operation !== "replan" || + args.expected_plan_id !== input.authority.plan_id || + args.expected_version !== expected.version || + args.replan_reason !== input.expectedReason || + args.goal !== input.authority.goal || + "active_step_id" in args || + "assumptions" in args + ) { + throw new Error(`${input.caseName} replan parameters were not authoritative: ${JSON.stringify(args)}`) + } + const steps = array(args.steps, `${input.caseName} replan steps ${index + 1}`).map((step, stepIndex) => + record(step, `${input.caseName} replan step ${index + 1}.${stepIndex + 1}`), + ) + if (steps.length !== input.authority.steps.length + input.expectedNewTitles.length) { + throw new Error(`${input.caseName} replan supplied the wrong number of steps`) + } + input.authority.steps.forEach((authority, stepIndex) => { + const step = steps[stepIndex]! + if (step.step_id !== authority.step_id || step.status !== input.expectedStatuses[authority.title]) { + throw new Error(`${input.caseName} changed retained step identity ${authority.title}`) + } + for (const key of Object.keys(step)) { + if (!new Set(["step_id", "status", "title", "acceptance", "assigned_agent", "note"]).has(key)) { + throw new Error(`${input.caseName} retained step supplied unsupported field ${key}`) + } + } + if ( + (step.title !== undefined && step.title !== authority.title) || + (step.acceptance !== undefined && step.acceptance !== authority.acceptance) || + (step.assigned_agent !== undefined && step.assigned_agent !== authority.assigned_agent) || + (step.note !== undefined && step.note !== (expected.notes?.[authority.title] ?? authority.note)) + ) { + throw new Error(`${input.caseName} guessed or mutated hidden identity for ${authority.title}`) + } + }) + input.expectedNewTitles.forEach((title, newIndex) => { + const step = steps[input.authority.steps.length + newIndex]! + if ("step_id" in step) throw new Error(`${input.caseName} supplied an ID for new step ${title}`) + assertKeys(input.caseName, step, new Set(["title", "status"])) + if (step.title !== title || step.status !== input.expectedStatuses[title]) { + throw new Error(`${input.caseName} supplied the wrong new step ${title}`) + } + }) + if (metadata.plan_protocol !== expected.protocol) { + throw new Error(`${input.caseName} expected ${expected.protocol}, received ${String(metadata.plan_protocol)}`) + } + assertPlanArgumentReceipts(input.caseName, call, input.observation.durability, expected.protocol) + }) + + const plan = input.observation.plan?.document + const ref = input.observation.plan?.ref + if (!plan || !ref || ref.version !== input.expectedVersion) { + throw new Error(`${input.caseName} expected Plan version ${input.expectedVersion}`) + } + if ( + plan.plan_id !== input.authority.plan_id || + plan.goal !== input.authority.goal || + JSON.stringify(plan.assumptions) !== JSON.stringify(input.authority.assumptions) + ) { + throw new Error(`${input.caseName} changed Plan identity or omitted assumptions`) + } + input.authority.steps.forEach((authority, index) => { + const step = plan.steps[index] + if ( + !step || + step.step_id !== authority.step_id || + step.title !== authority.title || + step.acceptance !== authority.acceptance || + step.assigned_agent !== authority.assigned_agent || + step.status !== input.expectedStatuses[authority.title] || + (step.note ?? null) !== (input.expectedNotes?.[authority.title] ?? authority.note ?? null) + ) { + throw new Error(`${input.caseName} failed to preserve retained authority for ${authority.title}`) + } + }) + const allocated = plan.steps.slice(input.authority.steps.length) + if ( + allocated.length !== input.expectedNewTitles.length || + allocated.some( + (step, index) => + step.title !== input.expectedNewTitles[index] || + step.status !== input.expectedStatuses[step.title] || + input.authority.steps.some((authority) => authority.step_id === step.step_id), + ) + ) { + throw new Error(`${input.caseName} did not allocate the expected new steps`) + } + const active = plan.steps.find((step) => step.title === input.expectedActiveTitle) + if (!active || active.status !== "active" || plan.active_step_id !== active.step_id) { + throw new Error(`${input.caseName} did not derive the expected active step`) + } + return plan +} + +function requirePlanCalls(caseName: string, observation: PlanObservation, count: number) { + const calls = observation.newTools.filter((tool) => tool.name === "plan") + if (calls.length !== count || calls.length !== observation.newTools.length) { + throw new Error(`${caseName} tool sequence mismatch: ${observation.newTools.map((tool) => tool.name).join(", ")}`) + } + if (calls.some((call) => call.status !== "completed")) throw new Error(`${caseName} Plan call did not complete`) + return calls +} + +function assertKeys(caseName: string, value: Record, allowed: ReadonlySet) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${caseName} supplied unsupported field ${key}`) + } +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} is not an object`) + return value as Record +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} is not an array`) + return value +} diff --git a/packages/deepagent-code/script/live-llm/routes.ts b/packages/deepagent-code/script/live-llm/routes.ts index b1f28a09..3003b521 100644 --- a/packages/deepagent-code/script/live-llm/routes.ts +++ b/packages/deepagent-code/script/live-llm/routes.ts @@ -31,6 +31,9 @@ export const modelSuites = [ "stale-validation", "continuation-repetition", "degeneration", + "activity-progress-lifecycle", + "activity-progress-restart", + "activity-progress-package", "subagent-finalizer-isolation", "steer-boundary", "subagent-worktree-routing", @@ -49,6 +52,7 @@ export const modelSuites = [ "prompt-intent-fencing", "subagent-control-plane", "plan-advance-contract", + "plan-create-replan-contract", ] as const export type ExecutionStack = (typeof executionStacks)[number] @@ -105,6 +109,9 @@ const shellExitContract = modelRun("live", "legacy-session", "shell-exit-contrac const staleValidation = modelRun("live", "legacy-session", "stale-validation") const continuationRepetition = modelRun("live", "legacy-session", "continuation-repetition") const degeneration = modelRun("live", "legacy-session", "degeneration") +const activityProgressLifecycle = modelRun("live", "legacy-session", "activity-progress-lifecycle") +const activityProgressRestart = modelRun("live", "packaged-sidecar", "activity-progress-restart") +const activityProgressPackage = modelRun("ext", "renderer-ui", "activity-progress-package") const finalizerIsolation = modelRun("ext", "legacy-session", "subagent-finalizer-isolation") const steerBoundary = modelRun("live", "legacy-session", "steer-boundary") const worktreeRouting = modelRun("ext", "legacy-session", "subagent-worktree-routing") @@ -123,6 +130,7 @@ const intelligenceDraft = modelRun("ext", "legacy-session", "intelligence-draft- const promptIntentFencing = modelRun("ext", "legacy-session", "prompt-intent-fencing") const subagentControlPlane = modelRun("live", "legacy-session", "subagent-control-plane") const planAdvanceContract = modelRun("live", "legacy-session", "plan-advance-contract") +const planCreateReplanContract = modelRun("live", "legacy-session", "plan-create-replan-contract") const allHarnessRuns = [ adapterProvider, cliHeadless, @@ -140,6 +148,9 @@ const allHarnessRuns = [ staleValidation, continuationRepetition, degeneration, + activityProgressLifecycle, + activityProgressRestart, + activityProgressPackage, finalizerIsolation, steerBoundary, worktreeRouting, @@ -162,6 +173,7 @@ const allHarnessRuns = [ promptIntentFencing, subagentControlPlane, planAdvanceContract, + planCreateReplanContract, ] export const routeManifest = [ @@ -284,6 +296,15 @@ export const routeManifest = [ checks: ["live-llm-routes", "llm-adapter"], runs: [planAdvanceContract], }, + { + id: "live-llm-plan-create-replan-contract-harness", + paths: [ + "packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts", + "packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts", + ], + checks: ["live-llm-routes", "llm-adapter"], + runs: [planCreateReplanContract], + }, { id: "live-llm-degeneration-harness", paths: ["packages/deepagent-code/script/live-llm/degeneration.ts"], @@ -302,6 +323,35 @@ export const routeManifest = [ checks: ["session-continuation"], runs: [steerBoundary], }, + { + id: "live-llm-activity-progress-harness", + paths: [ + "packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts", + "packages/deepagent-code/script/live-llm/activity-progress-oracle.ts", + ], + checks: ["live-llm-routes", "session-continuation"], + runs: [activityProgressLifecycle], + }, + { + id: "live-llm-activity-progress-restart-harness", + paths: [ + "packages/desktop/scripts/live-llm/activity-progress-restart.ts", + "packages/desktop/scripts/live-llm/runtime.ts", + ], + checks: ["desktop-runtime", "prompt-intent", "session-continuation"], + runs: [activityProgressRestart], + }, + { + id: "live-llm-activity-progress-package-harness", + paths: [ + "packages/desktop/electron-builder.config.ts", + "packages/desktop/scripts/live-llm/activity-progress-package.ts", + "packages/desktop/scripts/live-llm/runtime.ts", + "packages/desktop/scripts/package.ts", + ], + checks: ["desktop-runtime", "ui-runtime"], + runs: [activityProgressPackage], + }, { id: "live-llm-worktree-routing-harness", paths: ["packages/deepagent-code/script/live-llm/subagent-worktree.ts"], @@ -600,7 +650,24 @@ export const routeManifest = [ "packages/deepagent-code/src/tool/plan*.{ts,txt}", ], checks: ["live-llm-routes", "llm-adapter", "permission"], - runs: [planAdvanceContract], + runs: [planAdvanceContract, planCreateReplanContract], + }, + { + id: "activity-progress-lifecycle-production", + paths: [ + "packages/app/src/pages/session/message-timeline.data.ts", + "packages/core/src/database/migration/20260811090000_legacy_activity_progress.ts", + "packages/core/src/database/migration/20260811100000_legacy_activity_owner.ts", + "packages/deepagent-code/src/session/activity-crash-test.ts", + "packages/deepagent-code/src/session/activity-owner.ts", + "packages/deepagent-code/src/session/activity-sql.ts", + "packages/deepagent-code/src/session/message-v2.ts", + "packages/deepagent-code/src/session/prompt-intent.ts", + "packages/deepagent-code/src/session/prompt.ts", + "packages/deepagent-code/src/session/steer.ts", + ], + checks: ["live-llm-routes", "prompt-intent", "session-continuation", "ui-runtime"], + runs: [activityProgressLifecycle, activityProgressRestart, activityProgressPackage], }, { id: "legacy-session-prompt", diff --git a/packages/deepagent-code/script/live-llm/runtime.ts b/packages/deepagent-code/script/live-llm/runtime.ts index 35b9a1b3..dd0cbaa7 100644 --- a/packages/deepagent-code/script/live-llm/runtime.ts +++ b/packages/deepagent-code/script/live-llm/runtime.ts @@ -255,8 +255,16 @@ export async function runLegacyLiveCases(input: { const { Question } = await import("../../src/question") const { SessionCompaction } = await import("../../src/session/compaction") const { CompactionRunTable, CompactionSummaryAttemptTable } = await import("../../src/session/compaction-sql") + const { + SessionActivityAdmissionTable, + SessionActivityProgressTable, + SessionLegacyActivityAdmissionTable, + SessionLegacyActivityTable, + } = await import("../../src/session/activity-sql") const { SessionPromptEpochTable } = await import("../../src/session/prompt-epoch.sql") - const { SessionWorldStateBaselineTable } = await import("@deepagent-code/core/session/sql") + const { PartTable, SessionIntentTable, SessionWorldStateBaselineTable } = await import( + "@deepagent-code/core/session/sql" + ) const { SessionPromptIntent } = await import("../../src/session/prompt-intent") const { SessionPrompt } = await import("../../src/session/prompt") const { SessionRevert } = await import("../../src/session/revert") @@ -266,7 +274,6 @@ export async function runLegacyLiveCases(input: { const { Session } = await import("../../src/session/session") const { SessionToolArgumentReceiptTable } = await import("../../src/session/tool-argument-receipt.sql") const { SessionToolRequestReceiptTable } = await import("../../src/session/tool-request-receipt.sql") - const { SessionIntentTable } = await import("@deepagent-code/core/session/sql") const { EventDispatcher } = await import("../../src/session/event-dispatcher") const { MultiAgentRuntime } = await import("../../src/session/multi-agent-runtime") const { makeEventTurnRunner } = await import("../../src/session/v4-event-runtime") @@ -516,25 +523,28 @@ export async function runLegacyLiveCases(input: { const refFiles = yield* Effect.promise(async () => Object.fromEntries( await Promise.all( - refs.map(async (ref) => [ - ref, - Object.fromEntries( - await Promise.all( - (input.inspectFiles ?? []).map(async (file) => { - const child = Bun.spawn(["git", "show", `${ref}:${file}`], { - cwd: instance.directory, - stdout: "pipe", - stderr: "ignore", - }) - const [content, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - child.exited, - ]) - return [file, exitCode === 0 ? content : undefined] as const - }), - ), - ), - ] as const), + refs.map( + async (ref) => + [ + ref, + Object.fromEntries( + await Promise.all( + (input.inspectFiles ?? []).map(async (file) => { + const child = Bun.spawn(["git", "show", `${ref}:${file}`], { + cwd: instance.directory, + stdout: "pipe", + stderr: "ignore", + }) + const [content, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + child.exited, + ]) + return [file, exitCode === 0 ? content : undefined] as const + }), + ), + ), + ] as const, + ), ), ), ) @@ -598,7 +608,9 @@ export async function runLegacyLiveCases(input: { entries: collaborationEntries, approvals, branch: yield* gitService.branch(instance.directory), - worktrees: (yield* gitService.run(["worktree", "list", "--porcelain"], { cwd: instance.directory })).text(), + worktrees: (yield* gitService.run(["worktree", "list", "--porcelain"], { + cwd: instance.directory, + })).text(), }, refFiles, permissionRequests: permissionRequests.filter((request) => sessionIDs.includes(request.sessionID)), @@ -649,17 +661,17 @@ export async function runLegacyLiveCases(input: { yield* revert.revert({ sessionID: session.id, messageID: target.messageID }) const epochAfter = yield* sessions.mutationEpoch(session.id).pipe(Effect.orDie) const retry = testCase.revertBefore!.retryTargetIntent - ? yield* prompts - .promptAsync({ ...target, messageID: MessageID.ascending() }) - .pipe( - Effect.as({ accepted: true as const }), - Effect.catch((error) => - Effect.succeed({ accepted: false as const, error: liveErrorName(error) }), - ), - ) + ? yield* prompts.promptAsync({ ...target, messageID: MessageID.ascending() }).pipe( + Effect.as({ accepted: true as const }), + Effect.catch((error) => + Effect.succeed({ accepted: false as const, error: liveErrorName(error) }), + ), + ) : undefined if (retry?.accepted) { - return yield* Effect.die(new Error("A pre-revert prompt intent was admitted in a newer mutation epoch")) + return yield* Effect.die( + new Error("A pre-revert prompt intent was admitted in a newer mutation epoch"), + ) } yield* revert.cleanup(yield* sessions.get(session.id).pipe(Effect.orDie), epochAfter) return { @@ -822,13 +834,17 @@ export async function runLegacyLiveCases(input: { message.info.role === "assistant", ) .slice(assistantCountBefore) - .findLast((message) => message.info.time.completed !== undefined || message.info.error !== undefined) + .findLast( + (message) => message.info.time.completed !== undefined || message.info.error !== undefined, + ) return !busy && assistant ? assistant : undefined }).pipe( Effect.repeat({ while: (result) => result === undefined, schedule: Schedule.spaced("50 millis") }), Effect.timeout(config.timeoutMs), Effect.flatMap((result) => - result ? Effect.succeed(result) : Effect.die(new Error("Admitted prompt produced no terminal assistant")), + result + ? Effect.succeed(result) + : Effect.die(new Error("Admitted prompt produced no terminal assistant")), ), ) }) @@ -894,7 +910,9 @@ export async function runLegacyLiveCases(input: { ? undefined : yield* Effect.gen(function* () { if (result.info.role !== "assistant") { - return yield* Effect.die(new Error(`Input-token override target ${testCase.name} was not assistant`)) + return yield* Effect.die( + new Error(`Input-token override target ${testCase.name} was not assistant`), + ) } const override = { originalInputTokens: result.info.tokens.input, @@ -1141,7 +1159,37 @@ export async function runLegacyLiveCases(input: { .all() .pipe(Effect.orDie) const receiptIDs = new Set(requestReceipts.map((receipt) => receipt.receipt_id)) + const legacyActivities = yield* database.db + .select() + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.session_id, session.id)) + .all() + .pipe(Effect.orDie) + const activityIDs = new Set(legacyActivities.map((activity) => activity.activity_id)) return { + activityAdmissions: yield* database.db + .select() + .from(SessionActivityAdmissionTable) + .where(eq(SessionActivityAdmissionTable.session_id, session.id)) + .all() + .pipe(Effect.orDie), + legacyActivities, + legacyActivityAdmissions: (yield* database.db + .select() + .from(SessionLegacyActivityAdmissionTable) + .all() + .pipe(Effect.orDie)).filter((admission) => activityIDs.has(admission.activity_id)), + activityProgress: (yield* database.db + .select() + .from(SessionActivityProgressTable) + .all() + .pipe(Effect.orDie)).filter((progress) => activityIDs.has(progress.activity_id)), + activityTextParts: (yield* database.db + .select({ id: PartTable.id, message_id: PartTable.message_id, data: PartTable.data }) + .from(PartTable) + .where(eq(PartTable.session_id, session.id)) + .all() + .pipe(Effect.orDie)).filter((part) => part.data.type === "text"), promptEpochs: yield* database.db .select() .from(SessionPromptEpochTable) diff --git a/packages/deepagent-code/script/models-data.ts b/packages/deepagent-code/script/models-data.ts index 9fe3f0db..567d8ea7 100644 --- a/packages/deepagent-code/script/models-data.ts +++ b/packages/deepagent-code/script/models-data.ts @@ -1,19 +1,24 @@ import { mkdir, rename, rm } from "node:fs/promises" import path from "node:path" -const repositorySnapshotFile = path.resolve(import.meta.dir, "../test/tool/fixtures/models-api.json") - export async function loadModelsData( options: { environment?: Readonly> cacheFile?: string - fallbackFiles?: readonly string[] requestTimeoutMs?: number } = {}, ) { const environment = options.environment ?? process.env + const channel = environment.DEEPAGENT_CODE_CHANNEL?.trim() + const buildMarker = + (channel !== undefined && channel !== "" && channel !== "dev") || + Boolean(environment.DEEPAGENT_CODE_VERSION?.trim()) || + Boolean(environment.DEEPAGENT_CODE_RELEASE?.trim()) const configuredFile = environment.MODELS_DEV_API_JSON?.trim() if (configuredFile) { + if (buildMarker) { + throw new Error("MODELS_DEV_API_JSON is not allowed for production builds; unset it to fetch models.dev") + } const configured = await readCatalog(configuredFile) if (!configured) throw new Error(`Configured models.dev snapshot is invalid: ${configuredFile}`) return result(configured, configuredFile) @@ -36,14 +41,7 @@ export async function loadModelsData( return result(remote, `${modelsURL}/api.json`) } - // Build inputs must not depend on the builder's DeepAgent/OpenCode runtime caches. A caller may - // pass explicit fallback files for tests or controlled builds; the default is the committed copy. - const fallbacks = options.fallbackFiles ?? [repositorySnapshotFile] - const cached = (await Promise.all(fallbacks.map(async (file) => ({ file, data: await readCatalog(file) })))).find( - (item): item is { file: string; data: Record } => item.data !== undefined, - ) - if (!cached) throw new Error(`Unable to load a valid models.dev catalog from ${modelsURL} or local snapshots`) - return result(cached.data, cached.file) + throw new Error(`Unable to load models.dev catalog from ${modelsURL}; refusing to use a local snapshot`) } function result(data: Record, source: string) { diff --git a/packages/deepagent-code/src/agent/__tests__/subagent-permissions.test.ts b/packages/deepagent-code/src/agent/__tests__/subagent-permissions.test.ts index a025516d..ddf00eb1 100644 --- a/packages/deepagent-code/src/agent/__tests__/subagent-permissions.test.ts +++ b/packages/deepagent-code/src/agent/__tests__/subagent-permissions.test.ts @@ -388,7 +388,7 @@ describe("subagentIsWriteType", () => { expect(subagentIsWriteType(a)).toBe(true) }) - // BUG-001-405 Fix-A regression: researcher profile must be read-only + // BUG-405-001 Fix-A regression: researcher profile must be read-only it("researcher profile (star-deny + read/grep/glob/list/webfetch/websearch/code_intel, NO bash) is read-only", () => { const researcherPermissions: PermissionV1.Rule[] = [ makeRule("*", "deny"), diff --git a/packages/deepagent-code/src/agent/agent.ts b/packages/deepagent-code/src/agent/agent.ts index 115e5fed..063346bb 100644 --- a/packages/deepagent-code/src/agent/agent.ts +++ b/packages/deepagent-code/src/agent/agent.ts @@ -301,7 +301,7 @@ export const layer = Layer.effect( // they read and report, they do not delegate or change files. (deriveSubagentSessionPermission // already denies `task` by default; the explicit deny here is belt-and-suspenders.) // - // BUG-001-405 Fix-A: `bash` is intentionally absent here. subagentIsWriteType() treats + // BUG-405-001 Fix-A: `bash` is intentionally absent here. subagentIsWriteType() treats // any `bash: allow` as write-capable (unrestricted shell can write files), so including // it caused researcher to be classified as a writer → clean-workspace gate blocked every // researcher task in a dirty repo. researcher/reviewer are read-only roles and must not @@ -317,7 +317,7 @@ export const layer = Layer.effect( grep: "allow", glob: "allow", list: "allow", - // bash intentionally omitted — see BUG-001-405 Fix-A comment above + // bash intentionally omitted — see BUG-405-001 Fix-A comment above git_read: "allow", webfetch: "allow", websearch: "allow", diff --git a/packages/deepagent-code/src/agent/pr-collaboration.ts b/packages/deepagent-code/src/agent/pr-collaboration.ts index 7f29a8b7..dcdac1c5 100644 --- a/packages/deepagent-code/src/agent/pr-collaboration.ts +++ b/packages/deepagent-code/src/agent/pr-collaboration.ts @@ -108,7 +108,7 @@ export const ensureSessionBranch = Effect.fn("PRCollaboration.ensureSessionBranc if (!repository) return false const status = yield* input.git.porcelainStatus(input.directory) if (!status?.clean) { - // BUG-001-405 Fix-C: truncate the path list to avoid flooding the parent model context. + // BUG-405-001 Fix-C: truncate the path list to avoid flooding the parent model context. // Dumping all dirty/untracked paths produced 86 k-char errors in observed sessions. const MAX_SHOWN = 10 const allPaths = status?.paths ?? [] diff --git a/packages/deepagent-code/src/session/activity-crash-test.ts b/packages/deepagent-code/src/session/activity-crash-test.ts new file mode 100644 index 00000000..3f134cfb --- /dev/null +++ b/packages/deepagent-code/src/session/activity-crash-test.ts @@ -0,0 +1,21 @@ +import { realpath, writeFile } from "node:fs/promises" +import path from "node:path" +import { Effect } from "effect" + +export type Point = "after_provider_streaming" | "after_provider_receipt_terminal" | "after_progress_settled" + +export function pause(point: Point) { + if (process.env.DEEPAGENT_CODE_TEST_ACTIVITY_CRASH_POINT !== point) return Effect.void + return Effect.promise(async () => { + const root = process.env.DEEPAGENT_CODE_TEST_ROOT + const marker = process.env.DEEPAGENT_CODE_TEST_ACTIVITY_CRASH_MARKER + if (!root || !marker) throw new Error("Activity crash injection requires an isolated test root and marker") + const resolvedRoot = await realpath(root) + const resolvedMarker = path.join(await realpath(path.dirname(marker)), path.basename(marker)) + if (resolvedMarker !== resolvedRoot && !resolvedMarker.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error("Activity crash marker must stay inside DEEPAGENT_CODE_TEST_ROOT") + } + await writeFile(resolvedMarker, `${JSON.stringify({ point, pid: process.pid, reachedAt: Date.now() })}\n`) + await new Promise(() => {}) + }) +} diff --git a/packages/deepagent-code/src/session/activity-owner.ts b/packages/deepagent-code/src/session/activity-owner.ts new file mode 100644 index 00000000..56b5897e --- /dev/null +++ b/packages/deepagent-code/src/session/activity-owner.ts @@ -0,0 +1,5 @@ +import { randomUUID } from "node:crypto" + +export const processOwnerToken = `${process.pid}:${randomUUID()}` + +export * as SessionActivityOwner from "./activity-owner" diff --git a/packages/deepagent-code/src/session/activity-sql.ts b/packages/deepagent-code/src/session/activity-sql.ts new file mode 100644 index 00000000..62888049 --- /dev/null +++ b/packages/deepagent-code/src/session/activity-sql.ts @@ -0,0 +1,73 @@ +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" + +export const SessionActivityAdmissionTable = sqliteTable( + "session_activity_admission", + { + admission_id: text().primaryKey(), + session_id: text().notNull(), + source_kind: text().$type<"legacy_intent" | "session_input">().notNull(), + legacy_intent_id: text(), + session_input_id: text(), + admitted_message_id: text().notNull(), + delivery: text().$type<"turn" | "steer" | "queue" | "goal_steer">().notNull(), + payload_fingerprint_kind: text().$type<"payload_hash" | "source_identity">().notNull(), + payload_fingerprint: text().notNull(), + created_at: integer().notNull(), + }, + (table) => [ + uniqueIndex("session_activity_admission_legacy_idx").on(table.legacy_intent_id), + uniqueIndex("session_activity_admission_input_idx").on(table.session_input_id), + ], +) + +export const SessionLegacyActivityAdmissionTable = sqliteTable( + "session_legacy_activity_admission", + { + activity_id: text().notNull(), + admission_id: text().notNull(), + ordinal: integer().notNull(), + role: text().$type<"trigger" | "steer">().notNull(), + attached_at: integer().notNull(), + }, + (table) => [ + uniqueIndex("session_legacy_activity_admission_ordinal_idx").on(table.activity_id, table.ordinal), + uniqueIndex("session_legacy_activity_admission_admission_idx").on(table.admission_id), + ], +) + +export const SessionLegacyActivityTable = sqliteTable( + "session_legacy_activity", + { + activity_id: text().primaryKey(), + session_id: text().notNull(), + ordinal: integer().notNull(), + trigger_admission_id: text().notNull(), + owner_token: text().notNull(), + state: text().$type<"active" | "settled" | "failed" | "interrupted" | "recovery_required">().notNull(), + terminal_reason: text(), + created_at: integer().notNull(), + settled_at: integer(), + }, + (table) => [uniqueIndex("session_legacy_activity_ordinal_idx").on(table.session_id, table.ordinal)], +) + +export const SessionActivityProgressTable = sqliteTable( + "session_activity_progress", + { + activity_id: text().notNull(), + revision: integer().notNull(), + assistant_message_id: text().notNull(), + text_part_id: text(), + provider_receipt_id: text().notNull(), + state: text().$type<"provisional" | "progress" | "final" | "interrupted" | "recovery_required">().notNull(), + finish_observed: text(), + response_fingerprint: text(), + created_at: integer().notNull(), + settled_at: integer(), + }, + (table) => [ + uniqueIndex("session_activity_progress_revision_idx").on(table.activity_id, table.revision), + uniqueIndex("session_activity_progress_assistant_idx").on(table.assistant_message_id), + uniqueIndex("session_activity_progress_receipt_idx").on(table.provider_receipt_id), + ], +) diff --git a/packages/deepagent-code/src/session/compaction.ts b/packages/deepagent-code/src/session/compaction.ts index 9e7780aa..feee4588 100644 --- a/packages/deepagent-code/src/session/compaction.ts +++ b/packages/deepagent-code/src/session/compaction.ts @@ -12,7 +12,13 @@ import { Plugin } from "@/plugin" import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { Database } from "@deepagent-code/core/database/database" -import { MessageTable, PartTable, SessionTable, SessionWorldStateBaselineTable } from "@deepagent-code/core/session/sql" +import { + MessageTable, + PartTable, + SessionHistoryStateTable, + SessionTable, + SessionWorldStateBaselineTable, +} from "@deepagent-code/core/session/sql" import { PromptEpoch } from "./prompt-epoch" import { CompactionArtifactTable, @@ -46,6 +52,7 @@ import { LLM } from "./llm" import { HistoryAuthority } from "./history-authority" import { Identifier } from "@/id/id" import { Project } from "@deepagent-code/core/project" +import { SessionPromptEpochTable } from "./prompt-epoch.sql" const log = Log.create({ service: "session.compaction" }) @@ -710,7 +717,7 @@ export const layer = Layer.effect( .pipe(Effect.orDie) }) - const failRun = (runID: string, kind: string) => + const failRun = (runID: string, kind: string, recoverySessionID?: SessionID) => db .transaction( (tx) => @@ -730,6 +737,34 @@ export const layer = Layer.effect( .set({ state: "orphaned" }) .where(and(eq(CompactionArtifactTable.run_id, runID), eq(CompactionArtifactTable.state, "pending"))) .run() + if (recoverySessionID) { + const now = Date.now() + const reason = `compaction ${runID} failed with ${kind}; deterministic history recovery is required` + yield* tx + .update(SessionPromptEpochTable) + .set({ authority_state: "recovery_required", recovery_reason: reason }) + .where( + and( + eq(SessionPromptEpochTable.session_id, recoverySessionID), + eq(SessionPromptEpochTable.state, "active"), + ), + ) + .run() + yield* tx + .insert(SessionHistoryStateTable) + .values({ + session_id: recoverySessionID, + state: "recovery_required", + reason, + time_created: now, + time_updated: now, + }) + .onConflictDoUpdate({ + target: SessionHistoryStateTable.session_id, + set: { state: "recovery_required", reason, time_updated: now }, + }) + .run() + } }), { behavior: "immediate" }, ) @@ -1313,7 +1348,7 @@ export const layer = Layer.effect( }).toObject() currentProcessor.message.finish = "error" yield* session.updateMessage(currentProcessor.message) - yield* failRun(run.run_id, "summary_context_overflow") + yield* failRun(run.run_id, "summary_context_overflow", input.sessionID) return "stop" } diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts index d6356e82..6a437ce2 100644 --- a/packages/deepagent-code/src/session/message-v2.ts +++ b/packages/deepagent-code/src/session/message-v2.ts @@ -336,7 +336,7 @@ export function messagesInTransaction( function providerMeta(metadata: Record | undefined) { if (!metadata) return undefined - const { providerExecuted: _, ...rest } = metadata + const { providerExecuted: _, deepagent_activity_progress: __, ...rest } = metadata return Object.keys(rest).length > 0 ? rest : undefined } @@ -347,6 +347,57 @@ function toolCallProviderMeta(metadata: Record | undefined, differe return { deepagent: { toolType: type } } } +type ReasoningReplay = + | { readonly mode: "none" } + | { readonly mode: "active-continuation" } + | { readonly mode: "signed-prefix"; readonly metadataKey: "anthropic" | "bedrock" } + | { readonly mode: "encrypted-prefix" } + +function reasoningReplayCapability(model: Provider.Model): ReasoningReplay { + if (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") { + return { mode: "signed-prefix", metadataKey: "anthropic" } + } + if (model.api.npm === "@ai-sdk/amazon-bedrock") { + return { mode: "signed-prefix", metadataKey: "bedrock" } + } + if ( + model.api.npm === "@ai-sdk/openai" || + model.api.npm === "@ai-sdk/azure" || + model.api.npm === "@ai-sdk/github-copilot" || + model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + return { mode: "encrypted-prefix" } + } + if (model.capabilities.interleaved !== false) return { mode: "active-continuation" } + return { mode: "none" } +} + +function hasReasoningState(part: SessionV1.ReasoningPart, replay: ReasoningReplay) { + if (replay.mode === "active-continuation") return true + if (replay.mode === "signed-prefix") { + const state = part.metadata?.[replay.metadataKey] + return [state?.signature, state?.redactedData].some((value) => typeof value === "string" && value.trim().length > 0) + } + if (replay.mode === "encrypted-prefix") { + const state = part.metadata?.openai + return [state?.reasoningEncryptedContent, state?.encryptedContent].some( + (value) => typeof value === "string" && value.trim().length > 0, + ) + } + return false +} + +function isActivityProgress(part: SessionV1.TextPart) { + const progress = part.metadata?.deepagent_activity_progress + return ( + progress?.state === "progress" && + typeof progress.activity_id === "string" && + progress.activity_id.length > 0 && + Number.isInteger(progress.revision) && + progress.revision >= 0 + ) +} + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, @@ -354,6 +405,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( ) { const result: UIMessage[] = [] const toolNames = new Set() + const reasoningReplay = reasoningReplayCapability(model) // Track media from tool results that need to be injected as user messages // for providers that don't support that media type in tool results. // @@ -462,6 +514,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` + const isActive = !options?.terminalBoundaryID || msg.info.id > options.terminalBoundaryID const media: Array<{ mime: string; url: string; filename?: string }> = [] if ( @@ -489,17 +542,25 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing // the neighboring signed reasoning blocks. - const hasSignedReasoning = msg.parts.some((part) => { - if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null - }) + const hasSignedReasoning = + !differentModel && + msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return ( + reasoningReplay.mode === "signed-prefix" && + reasoningReplay.metadataKey === "anthropic" && + hasReasoningState(part, reasoningReplay) + ) + }) + const settledActivityProgress = msg.parts.some((part) => part.type === "text" && isActivityProgress(part)) for (const part of msg.parts) { if (part.type === "text") { + if (!isActive && settledActivityProgress) continue const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentModel ? {} : { providerMetadata: providerMeta(part.metadata) }), }) } if (part.type === "step-start") @@ -587,11 +648,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { - // Same-model reasoning is part of the append-only provider prefix. Removing it when a - // later terminal message settles rewrites history and invalidates the prompt cache. - // Cross-model projection has no reusable provider cache and can still drop settled - // reasoning to avoid feeding another model's chain of thought back as ordinary text. - const isActive = !options?.terminalBoundaryID || msg.info.id > options.terminalBoundaryID if (differentModel) { if (!isActive) continue if (part.text.trim().length > 0) @@ -601,6 +657,11 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) continue } + // Replay only protocol state the selected provider can consume. Plain same-model + // reasoning is audit history, not an append-only provider prefix; after settlement it + // must not become input to a new user activity. + if (!hasReasoningState(part, reasoningReplay)) continue + if (reasoningReplay.mode === "active-continuation" && !isActive) continue assistantMessage.parts.push({ type: "reasoning", text: part.text, diff --git a/packages/deepagent-code/src/session/processor.ts b/packages/deepagent-code/src/session/processor.ts index c6f850ca..c1446db6 100644 --- a/packages/deepagent-code/src/session/processor.ts +++ b/packages/deepagent-code/src/session/processor.ts @@ -157,7 +157,7 @@ export class ToolSequenceTracker { this.equivalentResultCount = 0 return undefined } - const resultSignature = `${toolName}:${canonicalJson(resolved)}` + const resultSignature = `${this.calls[idx].fingerprint}:${canonicalJson(resolved)}` const progressSignature = canonicalJson(progress) this.equivalentResultCount = resultSignature === this.previousResultSignature && progressSignature === this.previousProgressSignature @@ -328,21 +328,22 @@ export const restorePlanProtocolFailures = (messages: readonly PlanProtocolHisto ) const uniqueAttempts = [ ...new Map( - attempts.map((attempt) => [attempt.messageID + "\x00" + (attempt.part.callID ?? attempt.part.id), attempt] as const), + attempts.map( + (attempt) => [attempt.messageID + "\x00" + (attempt.part.callID ?? attempt.part.id), attempt] as const, + ), ).values(), ] - return uniqueAttempts - .reduce((consecutive, item) => { - const metadata = item.part.state && isRecord(item.part.state.metadata) ? item.part.state.metadata : undefined - const protocol = metadata?.plan_protocol - if (protocol === "success" || protocol === "progress") return 0 - if (!(protocol === "invalid" || protocol === "conflict" || protocol === "schema" || protocol === "no_progress")) - return consecutive - const ordinal = metadata?.plan_attempt_ordinal - return typeof ordinal === "number" && Number.isSafeInteger(ordinal) && ordinal > 0 - ? Math.max(consecutive + 1, ordinal) - : consecutive + 1 - }, 0) + return uniqueAttempts.reduce((consecutive, item) => { + const metadata = item.part.state && isRecord(item.part.state.metadata) ? item.part.state.metadata : undefined + const protocol = metadata?.plan_protocol + if (protocol === "success" || protocol === "progress") return 0 + if (!(protocol === "invalid" || protocol === "conflict" || protocol === "schema" || protocol === "no_progress")) + return consecutive + const ordinal = metadata?.plan_attempt_ordinal + return typeof ordinal === "number" && Number.isSafeInteger(ordinal) && ordinal > 0 + ? Math.max(consecutive + 1, ordinal) + : consecutive + 1 + }, 0) } /** @@ -1342,7 +1343,11 @@ export const layer = Layer.effect( value.name === "plan" ? ctx.planTracker?.settle(planTrackerCallID(value.id), "schema") : undefined if (protocol) { yield* recordProcessorValidation(value.id, "schema_invalid") - yield* persistMissingPlanToolCall(value.id, protocol, "Plan result arrived without a durable tool call.") + yield* persistMissingPlanToolCall( + value.id, + protocol, + "Plan result arrived without a durable tool call.", + ) if (protocol.terminal) yield* Effect.fail( new SessionV1.PlanProtocolViolationError({ @@ -1497,7 +1502,9 @@ export const layer = Layer.effect( yield* persistMissingPlanToolCall( value.id, toolCall ? undefined : protocol, - schemaInvalid ? "Plan tool input failed schema validation before a durable tool call was written." : value.message, + schemaInvalid + ? "Plan tool input failed schema validation before a durable tool call was written." + : value.message, ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (mirrorAssistant) { diff --git a/packages/deepagent-code/src/session/prompt-epoch.ts b/packages/deepagent-code/src/session/prompt-epoch.ts index c18ed0f9..c89a0fff 100644 --- a/packages/deepagent-code/src/session/prompt-epoch.ts +++ b/packages/deepagent-code/src/session/prompt-epoch.ts @@ -1,6 +1,6 @@ -// BUG-005: PromptEpoch — the unique model history boundary authority. +// BUG-405-005: PromptEpoch — the unique model history boundary authority. // -// Design contract (docs/4.0.4_r6.md §11, docs/bug-005-405.md §4.4): +// Design contract (docs/4.0.4_r6.md §11, docs/bug-405-005.md §4.4): // - One "active" epoch per session at most (enforced by partial unique index). // - Epoch 0 is the bootstrap epoch: no checkpoint refs, full transcript selection. // - A new epoch is ONLY activated by CompactionCommitted — never by epoch-first writes. diff --git a/packages/deepagent-code/src/session/prompt-intent.ts b/packages/deepagent-code/src/session/prompt-intent.ts index 0e28d9c3..fab76dcb 100644 --- a/packages/deepagent-code/src/session/prompt-intent.ts +++ b/packages/deepagent-code/src/session/prompt-intent.ts @@ -1,4 +1,5 @@ import { Database } from "@deepagent-code/core/database/database" +import { Hash } from "@deepagent-code/core/util/hash" import { MessageTable, PartTable, @@ -7,11 +8,19 @@ import { SessionTable, } from "@deepagent-code/core/session/sql" import { SessionV1 } from "@deepagent-code/core/v1/session" -import { and, eq, sql } from "drizzle-orm" +import { and, eq, max, sql } from "drizzle-orm" import { Data, Effect, Types } from "effect" import { randomUUID } from "node:crypto" import { MessageID, SessionID } from "./schema" import { SessionMutationEpoch } from "./mutation-epoch" +import { + SessionActivityAdmissionTable, + SessionActivityProgressTable, + SessionLegacyActivityAdmissionTable, + SessionLegacyActivityTable, +} from "./activity-sql" +import { SessionToolRequestReceiptTable } from "./tool-request-receipt.sql" +import { SessionActivityOwner } from "./activity-owner" export type Source = "composer" | "intelligence" | "followup" | "rewrite" export type Variant = "original" | "rewritten" @@ -57,6 +66,21 @@ export type Claim = readonly receipt: Receipt & { readonly state: "admitted"; readonly messageID: MessageID } } +export type Activity = { + readonly activityID: string + readonly admissionID: string + readonly sessionID: SessionID + readonly state: "active" | "settled" | "failed" | "interrupted" | "recovery_required" +} + +export type Progress = { + readonly activityID: string + readonly revision: number + readonly assistantMessageID: MessageID + readonly textPartID?: string + readonly state: "provisional" | "progress" | "final" | "interrupted" | "recovery_required" +} + const leaseDuration = 30_000 const fromRow = (row: typeof SessionIntentTable.$inferSelect): Receipt => ({ @@ -527,6 +551,114 @@ export const materializeTurn = Effect.fn("SessionPromptIntent.materializeTurn")( return yield* Effect.fail( new Conflict({ intentID: input.receipt.intentID, reason: "intent admission ownership was lost" }), ) + if (!intent.selected_payload_hash) + return yield* Effect.fail( + new Conflict({ intentID: input.receipt.intentID, reason: "intent payload fingerprint is missing" }), + ) + const admissionID = Hash.sha256(`session-activity-admission:v1:legacy:${intent.intent_id}`) + yield* tx + .insert(SessionActivityAdmissionTable) + .values({ + admission_id: admissionID, + session_id: input.receipt.sessionID, + source_kind: "legacy_intent", + legacy_intent_id: intent.intent_id, + admitted_message_id: input.message.info.id, + delivery: "turn", + payload_fingerprint_kind: "payload_hash", + payload_fingerprint: intent.selected_payload_hash, + created_at: intent.time_created, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const admission = yield* tx + .select() + .from(SessionActivityAdmissionTable) + .where(eq(SessionActivityAdmissionTable.legacy_intent_id, intent.intent_id)) + .get() + .pipe(Effect.orDie) + if ( + !admission || + admission.session_id !== input.receipt.sessionID || + admission.admitted_message_id !== input.message.info.id || + admission.delivery !== "turn" || + admission.payload_fingerprint_kind !== "payload_hash" || + admission.payload_fingerprint !== intent.selected_payload_hash + ) + return yield* Effect.fail( + new Conflict({ intentID: input.receipt.intentID, reason: "activity admission identity conflicts" }), + ) + const existingActivity = yield* tx + .select() + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.trigger_admission_id, admissionID)) + .get() + .pipe(Effect.orDie) + const activityID = existingActivity?.activity_id ?? Hash.sha256(`session-legacy-activity:v1:${admissionID}`) + if (!existingActivity) { + const active = yield* tx + .select() + .from(SessionLegacyActivityTable) + .where( + and( + eq(SessionLegacyActivityTable.session_id, input.receipt.sessionID), + eq(SessionLegacyActivityTable.state, "active"), + ), + ) + .get() + .pipe(Effect.orDie) + if (active) + return yield* Effect.fail( + new Conflict({ + intentID: input.receipt.intentID, + reason: `legacy activity ${active.activity_id} requires recovery before a new turn`, + }), + ) + const latest = yield* tx + .select({ ordinal: max(SessionLegacyActivityTable.ordinal) }) + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.session_id, input.receipt.sessionID)) + .get() + .pipe(Effect.orDie) + yield* tx + .insert(SessionLegacyActivityTable) + .values({ + activity_id: activityID, + session_id: input.receipt.sessionID, + ordinal: (latest?.ordinal ?? -1) + 1, + trigger_admission_id: admissionID, + owner_token: SessionActivityOwner.processOwnerToken, + state: "active", + terminal_reason: null, + created_at: now, + settled_at: null, + }) + .run() + .pipe(Effect.orDie) + } + yield* tx + .insert(SessionLegacyActivityAdmissionTable) + .values({ + activity_id: activityID, + admission_id: admissionID, + ordinal: 0, + role: "trigger", + attached_at: now, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const membership = yield* tx + .select() + .from(SessionLegacyActivityAdmissionTable) + .where(eq(SessionLegacyActivityAdmissionTable.admission_id, admissionID)) + .get() + .pipe(Effect.orDie) + if (membership?.activity_id !== activityID || membership.ordinal !== 0 || membership.role !== "trigger") + return yield* Effect.fail( + new Conflict({ intentID: input.receipt.intentID, reason: "activity trigger membership conflicts" }), + ) return fromRow(admitted) }), { behavior: "immediate" }, @@ -534,6 +666,418 @@ export const materializeTurn = Effect.fn("SessionPromptIntent.materializeTurn")( .pipe(Effect.catchTag("SqlError", Effect.die)) }) +export const activityForMessage = Effect.fn("SessionPromptIntent.activityForMessage")(function* (input: { + readonly sessionID: SessionID + readonly messageID: MessageID +}) { + const { db } = yield* Database.Service + const row = yield* db + .select({ + activityID: SessionLegacyActivityTable.activity_id, + admissionID: SessionLegacyActivityAdmissionTable.admission_id, + sessionID: SessionLegacyActivityTable.session_id, + state: SessionLegacyActivityTable.state, + }) + .from(SessionLegacyActivityTable) + .innerJoin( + SessionLegacyActivityAdmissionTable, + eq(SessionLegacyActivityAdmissionTable.activity_id, SessionLegacyActivityTable.activity_id), + ) + .innerJoin( + SessionActivityAdmissionTable, + eq(SessionActivityAdmissionTable.admission_id, SessionLegacyActivityAdmissionTable.admission_id), + ) + .where( + and( + eq(SessionLegacyActivityTable.session_id, input.sessionID), + eq(SessionActivityAdmissionTable.admitted_message_id, input.messageID), + ), + ) + .get() + .pipe(Effect.orDie) + if (!row) return undefined + return { + activityID: row.activityID, + admissionID: row.admissionID, + sessionID: SessionID.make(row.sessionID), + state: row.state, + } satisfies Activity +}) + +export const beginProgress = Effect.fn("SessionPromptIntent.beginProgress")(function* (input: { + readonly activityID: string + readonly assistantMessageID: MessageID + readonly providerReceiptID: string +}) { + const { db } = yield* Database.Service + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const existing = yield* tx + .select() + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.assistant_message_id, input.assistantMessageID)) + .get() + if (existing) { + if (existing.activity_id !== input.activityID || existing.provider_receipt_id !== input.providerReceiptID) + return yield* Effect.die(new Error(`activity progress identity conflicts: ${input.assistantMessageID}`)) + return progress(existing) + } + const activity = yield* tx + .select() + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.activity_id, input.activityID)) + .get() + if (!activity || activity.state !== "active") + return yield* Effect.die(new Error(`legacy activity is not active: ${input.activityID}`)) + const latest = yield* tx + .select({ revision: max(SessionActivityProgressTable.revision) }) + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.activity_id, input.activityID)) + .get() + const row = { + activity_id: input.activityID, + revision: (latest?.revision ?? -1) + 1, + assistant_message_id: input.assistantMessageID, + text_part_id: null, + provider_receipt_id: input.providerReceiptID, + state: "provisional" as const, + finish_observed: null, + response_fingerprint: null, + created_at: Date.now(), + settled_at: null, + } + yield* tx.insert(SessionActivityProgressTable).values(row).run() + return progress(row) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +export const settleProgress = Effect.fn("SessionPromptIntent.settleProgress")(function* (input: { + readonly activityID: string + readonly assistantMessageID: MessageID +}) { + const { db } = yield* Database.Service + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const current = yield* tx + .select() + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.assistant_message_id, input.assistantMessageID)) + .get() + if (!current || current.activity_id !== input.activityID) + return yield* Effect.die(new Error(`activity progress is missing: ${input.assistantMessageID}`)) + if (current.state !== "provisional") return progress(current) + const receipt = yield* tx + .select() + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, current.provider_receipt_id)) + .get() + if (!receipt) + return yield* Effect.die(new Error(`provider receipt is missing: ${current.provider_receipt_id}`)) + if (!["settled", "failed", "indeterminate_after_crash"].includes(receipt.provider_state)) + return yield* Effect.die( + new Error(`provider receipt is not terminal: ${current.provider_receipt_id}: ${receipt.provider_state}`), + ) + const assistant = yield* tx + .select() + .from(MessageTable) + .where(eq(MessageTable.id, input.assistantMessageID)) + .get() + if (!assistant || assistant.session_id !== receipt.session_id || assistant.data.role !== "assistant") + return yield* Effect.die(new Error(`assistant response ownership mismatch: ${input.assistantMessageID}`)) + const assistantData = assistant.data as Omit + const currentAdmission = yield* tx + .select({ ordinal: SessionLegacyActivityAdmissionTable.ordinal }) + .from(SessionLegacyActivityAdmissionTable) + .innerJoin( + SessionActivityAdmissionTable, + eq(SessionActivityAdmissionTable.admission_id, SessionLegacyActivityAdmissionTable.admission_id), + ) + .where( + and( + eq(SessionLegacyActivityAdmissionTable.activity_id, input.activityID), + eq(SessionActivityAdmissionTable.admitted_message_id, assistantData.parentID), + ), + ) + .get() + const latestAdmission = yield* tx + .select({ ordinal: max(SessionLegacyActivityAdmissionTable.ordinal) }) + .from(SessionLegacyActivityAdmissionTable) + .where(eq(SessionLegacyActivityAdmissionTable.activity_id, input.activityID)) + .get() + const pendingAdmission = + currentAdmission && typeof latestAdmission?.ordinal === "number" + ? currentAdmission.ordinal < latestAdmission.ordinal + : false + const parts = yield* tx + .select() + .from(PartTable) + .where( + and( + eq(PartTable.message_id, input.assistantMessageID), + eq(PartTable.session_id, SessionID.make(receipt.session_id)), + ), + ) + .all() + const textParts = parts.filter((part) => part.data.type === "text") + const text = textParts.findLast((part) => { + if (part.data.type !== "text") return false + return (part.data as Omit).text.trim() !== "" + }) + const hasToolCalls = parts.some((part) => { + if (part.data.type !== "tool") return false + const data = part.data as Omit + if (data.metadata?.providerExecuted) return false + return !(data.state.status === "error" && data.state.metadata?.interrupted === true) + }) + const state = + receipt.provider_state === "indeterminate_after_crash" + ? "recovery_required" + : receipt.provider_state === "failed" + ? receipt.request_error_code === "AbortError" + ? "interrupted" + : "recovery_required" + : !assistantData.time.completed || !assistantData.finish + ? "recovery_required" + : assistantData.finish === "tool-calls" || + assistantData.finish === "length" || + hasToolCalls || + pendingAdmission + ? "progress" + : "final" + const now = Date.now() + const updated = yield* tx + .update(SessionActivityProgressTable) + .set({ + text_part_id: text?.id ?? null, + state, + finish_observed: assistantData.finish ?? receipt.request_error_code ?? null, + response_fingerprint: receipt.response_fingerprint, + settled_at: now, + }) + .where( + and( + eq(SessionActivityProgressTable.activity_id, input.activityID), + eq(SessionActivityProgressTable.revision, current.revision), + eq(SessionActivityProgressTable.state, "provisional"), + ), + ) + .returning() + .get() + if (!updated) + return yield* Effect.die(new Error(`activity progress settlement CAS lost: ${input.activityID}`)) + yield* Effect.forEach( + textParts, + (part) => { + const data = part.data as Omit + return tx + .update(PartTable) + .set({ + data: { + ...data, + metadata: { + ...(data.metadata ?? {}), + deepagent_activity_progress: { + activity_id: input.activityID, + revision: current.revision, + state, + }, + }, + } as typeof PartTable.$inferInsert.data, + }) + .where( + and( + eq(PartTable.id, part.id), + eq(PartTable.message_id, input.assistantMessageID), + eq(PartTable.session_id, SessionID.make(receipt.session_id)), + ), + ) + .run() + }, + { discard: true }, + ) + if (state !== "progress") { + const activityState = + state === "final" ? "settled" : state === "interrupted" ? "interrupted" : "recovery_required" + const terminal = yield* tx + .update(SessionLegacyActivityTable) + .set({ + state: activityState, + terminal_reason: assistantData.finish ?? receipt.request_error_code ?? state, + settled_at: now, + }) + .where( + and( + eq(SessionLegacyActivityTable.activity_id, input.activityID), + eq(SessionLegacyActivityTable.state, "active"), + ), + ) + .returning({ activityID: SessionLegacyActivityTable.activity_id }) + .get() + if (!terminal) + return yield* Effect.die(new Error(`legacy activity settlement CAS lost: ${input.activityID}`)) + } + return progress(updated) + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) +}) + +export const recoverActiveActivities = Effect.fn("SessionPromptIntent.recoverActiveActivities")(function* ( + ownerToken = SessionActivityOwner.processOwnerToken, +) { + const { db } = yield* Database.Service + const active = yield* db + .select({ activityID: SessionLegacyActivityTable.activity_id }) + .from(SessionLegacyActivityTable) + .where( + and( + eq(SessionLegacyActivityTable.state, "active"), + sql`${SessionLegacyActivityTable.owner_token} != ${ownerToken}`, + ), + ) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + active, + (activity) => + Effect.gen(function* () { + const latest = yield* db + .select() + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.activity_id, activity.activityID)) + .orderBy(sql`${SessionActivityProgressTable.revision} DESC`) + .get() + .pipe(Effect.orDie) + const receipt = latest + ? yield* db + .select({ state: SessionToolRequestReceiptTable.provider_state }) + .from(SessionToolRequestReceiptTable) + .where(eq(SessionToolRequestReceiptTable.receipt_id, latest.provider_receipt_id)) + .get() + .pipe(Effect.orDie) + : undefined + const settled = + latest?.state === "provisional" && + receipt && + ["settled", "failed", "indeterminate_after_crash"].includes(receipt.state) + ? yield* settleProgress({ + activityID: activity.activityID, + assistantMessageID: MessageID.make(latest.assistant_message_id), + }) + : undefined + if (settled && settled.state !== "progress") return + const recoveryProgressState = settled?.state ?? latest?.state + const recoveryReason = settled + ? "process restarted after settled activity progress" + : recoveryProgressState + ? `process restarted after activity progress ${recoveryProgressState}` + : "process restarted before provider progress admission" + yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const now = Date.now() + yield* tx + .update(SessionLegacyActivityTable) + .set({ + state: "recovery_required", + terminal_reason: recoveryReason, + settled_at: now, + }) + .where( + and( + eq(SessionLegacyActivityTable.activity_id, activity.activityID), + eq(SessionLegacyActivityTable.state, "active"), + ), + ) + .run() + if (!settled && latest?.state === "provisional") + yield* tx + .update(SessionActivityProgressTable) + .set({ state: "recovery_required", finish_observed: "process_restart", settled_at: now }) + .where( + and( + eq(SessionActivityProgressTable.activity_id, activity.activityID), + eq(SessionActivityProgressTable.revision, latest.revision), + eq(SessionActivityProgressTable.state, "provisional"), + ), + ) + .run() + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }), + { discard: true }, + ) + return active.length +}) + +export const interruptActivity = Effect.fn("SessionPromptIntent.interruptActivity")(function* (activityID: string) { + const { db } = yield* Database.Service + yield* db + .update(SessionLegacyActivityTable) + .set({ state: "interrupted", terminal_reason: "aborted_before_provider_settlement", settled_at: Date.now() }) + .where(and(eq(SessionLegacyActivityTable.activity_id, activityID), eq(SessionLegacyActivityTable.state, "active"))) + .run() + .pipe(Effect.orDie) +}) + +export const retireDisabledSteerActivity = Effect.fn("SessionPromptIntent.retireDisabledSteerActivity")(function* ( + sessionID: SessionID, +) { + const { db } = yield* Database.Service + const activity = yield* db + .select({ activityID: SessionLegacyActivityTable.activity_id }) + .from(SessionLegacyActivityTable) + .innerJoin( + SessionActivityAdmissionTable, + eq(SessionActivityAdmissionTable.admission_id, SessionLegacyActivityTable.trigger_admission_id), + ) + .where( + and( + eq(SessionLegacyActivityTable.session_id, sessionID), + eq(SessionLegacyActivityTable.state, "active"), + eq(SessionActivityAdmissionTable.delivery, "steer"), + ), + ) + .get() + .pipe(Effect.orDie) + if (!activity) return false + yield* db + .update(SessionLegacyActivityTable) + .set({ + state: "interrupted", + terminal_reason: "steering_disabled_before_absorption", + settled_at: Date.now(), + }) + .where( + and( + eq(SessionLegacyActivityTable.activity_id, activity.activityID), + eq(SessionLegacyActivityTable.state, "active"), + ), + ) + .run() + .pipe(Effect.orDie) + return true +}) + +const progress = (row: typeof SessionActivityProgressTable.$inferSelect): Progress => ({ + activityID: row.activity_id, + revision: row.revision, + assistantMessageID: MessageID.make(row.assistant_message_id), + ...(row.text_part_id ? { textPartID: row.text_part_id } : {}), + state: row.state, +}) + export const renew = Effect.fn("SessionPromptIntent.renew")(function* (input: { readonly intentID: string readonly ownerToken: string diff --git a/packages/deepagent-code/src/session/prompt.ts b/packages/deepagent-code/src/session/prompt.ts index a4fab3dd..b9ffd3c1 100644 --- a/packages/deepagent-code/src/session/prompt.ts +++ b/packages/deepagent-code/src/session/prompt.ts @@ -90,6 +90,7 @@ import { projectDurableSettledRun, projectRecoveredSubagentRun, TaskTool, type T import { SessionRunState } from "./run-state" import { SessionSteer } from "./steer" import { SessionPromptIntent } from "./prompt-intent" +import { pause as pauseAtActivityCrashPoint } from "./activity-crash-test" import { writeGovernanceAudit } from "./goal-governance-audit" import { RuntimeFlags } from "@/effect/runtime-flags" import { archiveSessionOnCompletion } from "@/wiki/session-archive" @@ -691,6 +692,14 @@ export const layer = Layer.effect( const database = yield* Database.Service const { db } = database yield* recoverProviderReceiptsOnStartup() + yield* SessionPromptIntent.recoverActiveActivities().pipe( + Effect.provideService(Database.Service, database), + Effect.tap((count) => + count > 0 + ? Effect.logWarning(`marked ${count} legacy activities recovery_required after restart`) + : Effect.void, + ), + ) const activeFederatedContexts = new Map() const settleFederatedActivity = (sessionID: SessionID, state: "settled" | "failed" | "interrupted") => Effect.gen(function* () { @@ -2210,6 +2219,63 @@ export const layer = Layer.effect( if (FSUtil.resolve(session.directory) !== FSUtil.resolve(current.directory)) { return yield* instances.provide({ directory: session.directory }, prompt(input, lifecycle)) } + if (!lifecycle?.intent) { + if (!flags.v4Steering) + yield* SessionPromptIntent.retireDisabledSteerActivity(input.sessionID).pipe( + Effect.provideService(Database.Service, database), + ) + const messageID = input.messageID ?? MessageID.ascending() + const intentID = input.intentID ?? `legacy-prompt:${input.sessionID}:${messageID}` + const claimed = yield* SessionPromptIntent.claim({ + intentID, + sessionID: input.sessionID, + source: input.intentSource ?? "composer", + variant: input.intentVariant ?? "original", + payloadHash: promptIntentPayloadHash(input), + messageID, + }).pipe(Effect.provideService(Database.Service, database)) + if (claimed.kind === "admitted") { + if (claimed.receipt.delivery !== "turn") + return yield* Effect.die( + new Error(`direct prompt intent ${claimed.receipt.intentID} was admitted as ${claimed.receipt.delivery}`), + ) + const existing = yield* MessageV2.get({ + sessionID: input.sessionID, + messageID: claimed.receipt.messageID, + }).pipe(Effect.provideService(Database.Service, database), Effect.orDie) + if (!existing) return yield* Effect.die(`admitted prompt message is missing: ${claimed.receipt.messageID}`) + return existing + } + const admittedInput = { + ...input, + messageID: claimed.receipt.messageID, + parts: stableIntentParts(input.parts, claimed.receipt.intentID), + } + const lifecycleWithIntent: PromptLifecycle = { + intent: claimed.receipt, + ready: (receipt) => + SessionPromptIntent.complete({ + intentID: claimed.receipt.intentID, + ownerToken: claimed.receipt.ownerToken, + messageID: receipt.messageID, + delivery: receipt.delivery, + }).pipe(Effect.provideService(Database.Service, database), Effect.asVoid, Effect.orDie), + } + return yield* prompt(admittedInput, lifecycleWithIntent).pipe( + Effect.tapError(() => + SessionPromptIntent.fail({ + intentID: claimed.receipt.intentID, + ownerToken: claimed.receipt.ownerToken, + }).pipe(Effect.provideService(Database.Service, database)), + ), + Effect.onInterrupt(() => + SessionPromptIntent.fail({ + intentID: claimed.receipt.intentID, + ownerToken: claimed.receipt.ownerToken, + }).pipe(Effect.provideService(Database.Service, database)), + ), + ) + } const mutationEpoch = lifecycle?.intent?.mutationEpoch ?? (yield* sessions.mutationEpoch(session.id).pipe(Effect.orDie)) yield* revert.cleanup(session, mutationEpoch) @@ -2753,6 +2819,28 @@ export const layer = Layer.effect( ), ) const initialUser = MessageV2.latest(initialMessages).user + let legacyActivity = initialUser + ? yield* SessionPromptIntent.activityForMessage({ sessionID, messageID: initialUser.id }).pipe( + Effect.provideService(Database.Service, database), + Effect.map((activity) => (activity?.state === "active" ? activity : undefined)), + ) + : undefined + const publishActivityProgress = (progress: SessionPromptIntent.Progress) => + Effect.gen(function* () { + if (!progress.textPartID) return + const messages = yield* sessions.messages({ sessionID }).pipe(Effect.orDie) + const parts = messages + .find((message) => message.info.id === progress.assistantMessageID) + ?.parts.filter( + (candidate): candidate is SessionV1.TextPart => + candidate.type === "text" && + candidate.metadata?.deepagent_activity_progress?.activity_id === progress.activityID && + candidate.metadata.deepagent_activity_progress.revision === progress.revision && + candidate.metadata.deepagent_activity_progress.state === progress.state, + ) + if (!parts?.length) return + yield* Effect.forEach(parts, sessions.updatePart, { discard: true }) + }) const initialFinalizer = isStructuredFinalizer(initialUser?.metadata) let activeContext: SessionFederatedContext.Resolved | undefined let pendingContextInputIds: string[] = drainFirst || !initialUser ? [] : [initialUser.id] @@ -2817,6 +2905,11 @@ export const layer = Layer.effect( const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs) if (!lastUser) throw new Error("No user message found in stream. This should never happen.") + const currentLegacyActivity = yield* SessionPromptIntent.activityForMessage({ + sessionID, + messageID: lastUser.id, + }).pipe(Effect.provideService(Database.Service, database)) + if (currentLegacyActivity?.state === "active") legacyActivity = currentLegacyActivity const finalizerMode = isStructuredFinalizer(lastUser.metadata) const finalizerAllowsText = structuredFinalizerAllowsText(lastUser.metadata) @@ -3134,11 +3227,17 @@ export const layer = Layer.effect( value?: { state: "settled" } | { state: "failed"; errorCode: string } } = {} const receiptFinalizer: { value?: () => Effect.Effect } = {} + const activityProgressFinalizer: { value?: () => Effect.Effect } = {} const finalizeInterruptedTurn = Effect.uninterruptible( Effect.gen(function* () { yield* finalizeInterruptedAssistant receiptTerminal.value ??= { state: "failed", errorCode: "AbortError" } if (receiptFinalizer.value) yield* receiptFinalizer.value() + if (activityProgressFinalizer.value) yield* activityProgressFinalizer.value() + if (legacyActivity && !activityProgressFinalizer.value) + yield* SessionPromptIntent.interruptActivity(legacyActivity.activityID).pipe( + Effect.provideService(Database.Service, database), + ) }), ) @@ -3191,7 +3290,7 @@ export const layer = Layer.effect( ToolSemanticFingerprint.resolve(tools[toolName], args), ) toolSequenceTracker.setResultFingerprintResolver((toolName, result) => - ToolSemanticFingerprint.resolveResult(tools[toolName], result), + ToolSemanticFingerprint.resolveResult(tools[toolName], result, toolName), ) const providerHistory = finalizerMode @@ -3199,10 +3298,9 @@ export const layer = Layer.effect( : (yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: structuredClone(msgs) })) .messages - // PR-1: Compute the terminal boundary for cross-model reasoning projection. - // The most recent settled assistant message (has finish, no pending tool calls) - // defines the boundary. Same-model reasoning remains append-only because removing - // signed thinking after settlement rewrites the provider prefix and busts its cache. + // The most recent settled assistant message defines where provider reasoning stops + // being an active tool continuation. Projection still preserves signed/encrypted + // provider prefixes when the selected protocol declares that replay capability. let terminalBoundaryID: MessageID | undefined for (const msg of providerHistory) { if (msg.info.role !== "assistant") continue @@ -3465,6 +3563,23 @@ export const layer = Layer.effect( { behavior: "immediate" }, ) .pipe(Effect.orDie) + const progressActivity = legacyActivity + if (progressActivity) + yield* SessionPromptIntent.beginProgress({ + activityID: progressActivity.activityID, + assistantMessageID: handle.message.id, + providerReceiptID: receiptID, + }).pipe(Effect.provideService(Database.Service, database)) + if (progressActivity) + activityProgressFinalizer.value = () => + SessionPromptIntent.settleProgress({ + activityID: progressActivity.activityID, + assistantMessageID: handle.message.id, + }).pipe( + Effect.provideService(Database.Service, database), + Effect.tap(publishActivityProgress), + Effect.asVoid, + ) const bestEffortReceiptWrite = (operation: string, write: Effect.Effect) => write.pipe( Effect.asVoid, @@ -3661,6 +3776,9 @@ export const layer = Layer.effect( Effect.gen(function* () { if (turnSettled.value) return yield* finalizeReceipt() + yield* pauseAtActivityCrashPoint("after_provider_receipt_terminal") + if (activityProgressFinalizer.value) yield* activityProgressFinalizer.value() + yield* pauseAtActivityCrashPoint("after_progress_settled") turnSettled.value = true // Summary diffs mutate user-message metadata. Run them only after the Provider // receipt is terminal so cancellation cannot strand an admitted request. @@ -3772,7 +3890,9 @@ export const layer = Layer.effect( to: "streaming", values: { streaming_at: Date.now() }, }) - if (transitioned && providerAttempt) yield* providerAttempt.streaming.pipe(Effect.orDie) + if (!transitioned) return + if (providerAttempt) yield* providerAttempt.streaming.pipe(Effect.orDie) + yield* pauseAtActivityCrashPoint("after_provider_streaming") }), // Processor cleanup durably completes the assistant after these callbacks. Keep the // terminal intent in memory until that cleanup and the response fingerprint are ready, @@ -4036,6 +4156,12 @@ export const layer = Layer.effect( return admitted }) + const drainPendingSteers = Effect.fn("SessionPrompt.drainPendingSteers")(function* (sessionID: SessionID) { + while (yield* steerBuffer.hasPending(sessionID, "steer")) { + yield* loop({ sessionID, drainFirst: true }) + } + }) + // V4.1 §S1.2 — the ingress decision. Both the HTTP prompt route and the IM agent executor call THIS // instead of prompt() directly, so the steer-vs-turn choice lives in exactly one place. // @@ -4100,8 +4226,17 @@ export const layer = Layer.effect( intent: lifecycle?.intent, }) if (lifecycle) yield* lifecycle.ready({ messageID: MessageID.make(admitted.id), delivery: "steer" }) - // Race guard (see header): a pure-drain turn absorbs a steer stranded by the isBusy→admit window. - yield* loop({ sessionID: input.sessionID, drainFirst: true }).pipe(Effect.ignore, Effect.forkIn(scope)) + // Race guard (see header): if this call joins a turn that already passed its final drain point, + // re-check the durable buffer after that runner settles and start a pure-drain turn. Repeat until + // the admitted steer is consumed so the isBusy→admit→runner-idle window cannot strand an activity. + yield* drainPendingSteers(input.sessionID).pipe( + Effect.catchCause((cause) => + Effect.logError("failed to drain raced steer").pipe( + Effect.annotateLogs({ sessionID: input.sessionID, steerID: admitted.id, cause }), + ), + ), + Effect.forkIn(scope), + ) return { kind: "steer" as const, delivery: "steer" as const, admitted } }) diff --git a/packages/deepagent-code/src/session/steer.ts b/packages/deepagent-code/src/session/steer.ts index b8396c20..8e8e491f 100644 --- a/packages/deepagent-code/src/session/steer.ts +++ b/packages/deepagent-code/src/session/steer.ts @@ -1,6 +1,7 @@ -import { and, asc, eq, inArray, isNull } from "drizzle-orm" +import { and, asc, eq, inArray, isNull, max } from "drizzle-orm" import { Context, Data, DateTime, Effect, Layer, Schema, Types } from "effect" import { Database } from "@deepagent-code/core/database/database" +import { Hash } from "@deepagent-code/core/util/hash" import { SessionInput } from "@deepagent-code/core/session/input" import { SessionMessage } from "@deepagent-code/core/session/message" import { Prompt } from "@deepagent-code/core/session/prompt" @@ -16,6 +17,12 @@ import { MessageID, SessionID } from "./schema" import type { Receipt } from "./prompt-intent" import { SessionMutationEpoch } from "./mutation-epoch" import { SessionPromptEpochTable } from "./prompt-epoch.sql" +import { + SessionActivityAdmissionTable, + SessionLegacyActivityAdmissionTable, + SessionLegacyActivityTable, +} from "./activity-sql" +import { SessionActivityOwner } from "./activity-owner" // V4.1 §S1.1 — the durable mid-turn STEER buffer. // @@ -195,32 +202,165 @@ export const layer = Layer.effect( return yield* Effect.fail( new CorrelationConflict({ sessionID: input.sessionID, correlationID: input.correlationID! }), ) - if (input.intent) { - const intent = yield* tx - .update(SessionIntentTable) - .set({ - state: "admitted", - delivery, - admitted_message_id: admitted.id, - owner_token: null, - lease_expires_at: null, - time_admitted: timeCreated, - time_updated: timeCreated, - version: input.intent.version + 1, - }) + const payloadHash = input.intent?.payloadHash ?? Hash.sha256(JSON.stringify(encodePrompt(input.prompt))) + if (!payloadHash) return yield* Effect.die("SessionSteer.admit: intent payload fingerprint is missing") + const intent = input.intent + ? yield* tx + .update(SessionIntentTable) + .set({ + state: "admitted", + delivery, + admitted_message_id: admitted.id, + owner_token: null, + lease_expires_at: null, + time_admitted: timeCreated, + time_updated: timeCreated, + version: input.intent.version + 1, + }) + .where( + and( + eq(SessionIntentTable.intent_id, input.intent.intentID), + eq(SessionIntentTable.session_id, input.sessionID), + eq(SessionIntentTable.state, "admitting"), + eq(SessionIntentTable.owner_token, input.intent.ownerToken), + eq(SessionIntentTable.mutation_epoch, session.mutationEpoch), + ), + ) + .returning() + .get() + .pipe(Effect.orDie) + : yield* tx + .insert(SessionIntentTable) + .values({ + intent_id: `legacy-steer:${input.sessionID}:${admitted.id}`, + session_id: input.sessionID, + source: "followup", + state: "admitted", + selected_variant: "original", + selected_payload_hash: payloadHash, + delivery, + admitted_message_id: admitted.id, + correlation_id: input.correlationID ?? admitted.id, + mutation_epoch: session.mutationEpoch, + version: 1, + time_created: admitted.timeCreated, + time_selected: admitted.timeCreated, + time_admitted: timeCreated, + time_updated: timeCreated, + }) + .onConflictDoNothing() + .returning() + .get() + .pipe(Effect.orDie) + const storedIntent = + intent ?? + (yield* tx + .select() + .from(SessionIntentTable) + .where(eq(SessionIntentTable.intent_id, `legacy-steer:${input.sessionID}:${admitted.id}`)) + .get() + .pipe(Effect.orDie)) + if ( + !storedIntent || + storedIntent.state !== "admitted" || + storedIntent.session_id !== input.sessionID || + storedIntent.admitted_message_id !== admitted.id || + storedIntent.delivery !== delivery || + storedIntent.selected_payload_hash !== payloadHash + ) + return yield* Effect.die("SessionSteer.admit: intent admission identity conflicts") + const admissionID = Hash.sha256(`session-activity-admission:v1:legacy:${storedIntent.intent_id}`) + yield* tx + .insert(SessionActivityAdmissionTable) + .values({ + admission_id: admissionID, + session_id: input.sessionID, + source_kind: "legacy_intent", + legacy_intent_id: storedIntent.intent_id, + admitted_message_id: admitted.id, + delivery, + payload_fingerprint_kind: "payload_hash", + payload_fingerprint: payloadHash, + created_at: storedIntent.time_created, + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const admission = yield* tx + .select() + .from(SessionActivityAdmissionTable) + .where(eq(SessionActivityAdmissionTable.legacy_intent_id, storedIntent.intent_id)) + .get() + .pipe(Effect.orDie) + if ( + admission?.session_id !== input.sessionID || + admission.admitted_message_id !== admitted.id || + admission.delivery !== delivery || + admission.payload_fingerprint_kind !== "payload_hash" || + admission.payload_fingerprint !== payloadHash + ) + return yield* Effect.die("SessionSteer.admit: activity admission identity conflicts") + if (delivery === "steer") { + const active = yield* tx + .select({ activityID: SessionLegacyActivityTable.activity_id }) + .from(SessionLegacyActivityTable) .where( and( - eq(SessionIntentTable.intent_id, input.intent.intentID), - eq(SessionIntentTable.session_id, input.sessionID), - eq(SessionIntentTable.state, "admitting"), - eq(SessionIntentTable.owner_token, input.intent.ownerToken), - eq(SessionIntentTable.mutation_epoch, session.mutationEpoch), + eq(SessionLegacyActivityTable.session_id, input.sessionID), + eq(SessionLegacyActivityTable.state, "active"), ), ) - .returning({ intentID: SessionIntentTable.intent_id }) .get() .pipe(Effect.orDie) - if (!intent) return yield* Effect.die("SessionSteer.admit: intent admission ownership was lost") + const activityID = active?.activityID ?? Hash.sha256(`session-legacy-activity:v1:${admissionID}`) + if (!active) { + const latest = yield* tx + .select({ ordinal: max(SessionLegacyActivityTable.ordinal) }) + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.session_id, input.sessionID)) + .get() + .pipe(Effect.orDie) + yield* tx + .insert(SessionLegacyActivityTable) + .values({ + activity_id: activityID, + session_id: input.sessionID, + ordinal: (latest?.ordinal ?? -1) + 1, + trigger_admission_id: admissionID, + owner_token: SessionActivityOwner.processOwnerToken, + state: "active", + created_at: timeCreated, + }) + .run() + .pipe(Effect.orDie) + } + const existingMembership = yield* tx + .select() + .from(SessionLegacyActivityAdmissionTable) + .where(eq(SessionLegacyActivityAdmissionTable.admission_id, admissionID)) + .get() + .pipe(Effect.orDie) + if (existingMembership && existingMembership.activity_id !== activityID) + return yield* Effect.die("SessionSteer.admit: steer activity membership conflicts") + if (!existingMembership) { + const latest = yield* tx + .select({ ordinal: max(SessionLegacyActivityAdmissionTable.ordinal) }) + .from(SessionLegacyActivityAdmissionTable) + .where(eq(SessionLegacyActivityAdmissionTable.activity_id, activityID)) + .get() + .pipe(Effect.orDie) + yield* tx + .insert(SessionLegacyActivityAdmissionTable) + .values({ + activity_id: activityID, + admission_id: admissionID, + ordinal: active ? (latest?.ordinal ?? 0) + 1 : 0, + role: active ? "steer" : "trigger", + attached_at: timeCreated, + }) + .run() + .pipe(Effect.orDie) + } } return admitted }), diff --git a/packages/deepagent-code/src/tool/git_read.ts b/packages/deepagent-code/src/tool/git_read.ts index 6025eb75..a99b0000 100644 --- a/packages/deepagent-code/src/tool/git_read.ts +++ b/packages/deepagent-code/src/tool/git_read.ts @@ -3,7 +3,7 @@ * * Permission name: "git_read" (intentionally absent from EDIT_CLASS_PERMISSIONS, * so subagentIsWriteType() returns false for agents that only hold this permission — - * see BUG-001-405 Fix-A in agent.ts). + * see BUG-405-001 Fix-A in agent.ts). * * Implementation note: this tool uses Node's child_process.execFile directly rather * than Git.Service so that it adds no new service requirement to the tool registry's diff --git a/packages/deepagent-code/src/tool/plan-write.ts b/packages/deepagent-code/src/tool/plan-write.ts index 490243db..771bf129 100644 --- a/packages/deepagent-code/src/tool/plan-write.ts +++ b/packages/deepagent-code/src/tool/plan-write.ts @@ -45,7 +45,7 @@ export const PlanEvent = { const PlanStep = Schema.Struct({ step_id: Schema.optional(Schema.String).annotate({ description: - "Stable id; required for advance, copy it for an unchanged replan step, and omit it for create or a genuinely new replan step so the server allocates it", + "Stable id; required for advance, copy it only for a retained replan step, and omit it for create or a new replan step. Create rejects supplied IDs; replan rejects unknown supplied IDs", }), title: Schema.optional(Schema.String).annotate({ description: "What this step does; required for create/replan and ignored for advance", @@ -94,7 +94,7 @@ export const Parameters = Schema.Struct({ }), active_step_id: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: - "For create/replan, omit this field and mark at most one step active; the server derives its allocated ID. For advance, copy a visible step_id, omit to retain it, or use null to clear it", + "For create/replan, omit this field because supplying it is rejected; mark at most one step active and the server derives its ID. For advance, copy a visible step_id, omit to retain it, or use null to clear it", }), }) export const PlanWriteParameters = Parameters @@ -239,7 +239,7 @@ export const PlanTool = Tool.define 0 ? `\n\n⚠ ${acceptanceWarnings.join("; ")}. Verify before finalizing.` : "" return { title: `Plan: ${done}/${total} steps`, - output: `${AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, version)}${changeSummary}${warnSummary}`, + output: renderModelPlanSuccess(plan, version, `${changeSummary}${warnSummary}`), metadata: { plan_id: plan.plan_id, goal: plan.goal, @@ -339,9 +339,9 @@ export const PlanTool = Tool.define, previous: ReturnType, @@ -358,18 +358,24 @@ export const normalizeModelPlanWrite = ( goal: params.goal ?? "", } - if (params.operation === "create" || previous == null) { - // Model-created IDs are never authoritative. Keep explicit null so a contradictory active status - // remains a validation error, and derive every non-null pointer after server allocation. - const deriveActive = params.active_step_id === undefined || params.active_step_id !== null + if (params.operation === "create") { + const suppliedIDs = params.steps.map((step) => step.step_id?.trim()).filter((stepID) => stepID !== undefined) + if (suppliedIDs.length > 0 || params.active_step_id !== undefined) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError("unsafe_step_identity", [ + ...new Set([...suppliedIDs, ...(typeof params.active_step_id === "string" ? [params.active_step_id] : [])]), + ]) + } return { ...base, assumptions: params.assumptions, - ...(deriveActive ? {} : { active_step_id: null }), - steps: params.steps.map((step) => ({ ...step, step_id: undefined, title: step.title ?? "" })), + steps: params.steps.map((step) => ({ ...step, title: step.title ?? "" })), } } + if (previous == null) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError("plan_missing") + } + if (params.operation === "advance") { const suppliedIDs = params.steps.map((step) => step.step_id?.trim() ?? "") if (suppliedIDs.some((stepID) => stepID === "")) { @@ -431,22 +437,16 @@ export const normalizeModelPlanWrite = ( previous.plan_id, ) } + if (params.active_step_id !== undefined) { + throw new AgentGateway.DeepAgentPlanController.PlanValidationError( + "unsafe_step_identity", + typeof params.active_step_id === "string" ? [params.active_step_id] : [], + previous.plan_id, + ) + } return { ...base, - goal: previous.goal, assumptions: params.assumptions === undefined ? [...previous.assumptions] : params.assumptions, - active_step_id: - params.active_step_id === undefined - ? undefined - : params.active_step_id !== null && - !params.steps.some((step) => step.step_id?.trim() === params.active_step_id?.trim()) && - params.steps.every((step) => (step.step_id?.trim() ?? "") === "") && - !knownIDs.has(params.active_step_id.trim()) && - params.steps.filter( - (step) => AgentGateway.DeepAgentPlanController.normalizePlanStepStatus(step.status) === "active", - ).length === 1 - ? undefined - : params.active_step_id, steps: params.steps.map((update) => { const stepID = update.step_id?.trim() ?? "" const prior = stepID === "" ? undefined : previous.steps.find((step) => step.step_id === stepID) @@ -463,44 +463,71 @@ export const normalizeModelPlanWrite = ( } export const renderModelPlanCorrection = ( - operation: Schema.Schema.Type["operation"], + params: Schema.Schema.Type, code: AgentGateway.DeepAgentPlanController.PlanValidationCode, previous: ReturnType, ref: ReturnType, ): string => { - if (operation === "advance") return renderPlanRetryBase(previous, ref) + if (params.operation === "advance") return renderPlanRetryBase(previous, ref) if (code === "plan_already_exists") { return ( "\n\nCorrection protocol: create cannot replace an existing plan. Use advance for status/note changes or replan for structural changes, with the exact authoritative precondition below." + renderPlanRetryBase(previous, ref) ) } - if (operation === "create") { + if (params.operation === "create") { return ( - "\n\nCorrection protocol for create: use " + - JSON.stringify({ expected_plan_id: null, expected_version: null }) + - ". Omit active_step_id; mark at most one step status=active and the server will allocate missing step_id values, then derive active_step_id. Do not invent a future server ID." + "\n\nCorrection protocol for create: copy the schema-valid payload below. It deliberately omits every step_id and active_step_id; the server allocates IDs and derives the active pointer. Do not invent a future server ID.\n" + + JSON.stringify({ + operation: "create", + expected_plan_id: null, + expected_version: null, + ...(params.goal !== undefined ? { goal: params.goal } : {}), + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + steps: params.steps.map((step) => ({ + ...(step.title !== undefined ? { title: step.title } : {}), + status: step.status, + ...(step.acceptance !== undefined ? { acceptance: step.acceptance } : {}), + ...(step.assigned_agent !== undefined ? { assigned_agent: step.assigned_agent } : {}), + ...(step.note !== undefined ? { note: step.note } : {}), + })), + }) ) } if (previous == null || ref == null) { return "\n\nAuthoritative replan parameters are unavailable. Do not guess expected_plan_id, expected_version, step_id, or active_step_id. If no plan exists, use create with null expected values." } return ( - "\n\nCorrection protocol for replan: copy the exact precondition below. For a retained step, copy its exact step_id, title, acceptance, and assigned_agent; the server also fills acceptance/assigned_agent when omitted. Omit step_id for every new step so the server allocates it. Omit active_step_id and mark at most one step status=active; the server derives its ID after allocation. Omit assumptions to retain the authoritative list, or send [] only when you intentionally clear it.\n" + + "\n\nCorrection protocol for replan: start from the schema-valid authoritative payload below. Retain a step only with its exact step_id; for every new step, omit step_id. Omit active_step_id and mark at most one step status=active so the server derives its ID after allocation. Omit assumptions to retain the authoritative list, or send [] only when you intentionally clear it.\n" + JSON.stringify({ + operation: "replan", expected_plan_id: previous.plan_id, expected_version: ref.version, - assumptions: previous.assumptions, - existing_steps: previous.steps.map((step) => ({ + replan_reason: params.replan_reason?.trim() || "Correct the rejected replan against current authority", + goal: params.goal ?? previous.goal, + ...(params.assumptions !== undefined ? { assumptions: params.assumptions } : {}), + steps: previous.steps.map((step) => ({ step_id: step.step_id, title: step.title, - acceptance: step.acceptance, - assigned_agent: step.assigned_agent, + status: step.status, + ...(step.acceptance != null ? { acceptance: step.acceptance } : {}), + ...(step.assigned_agent != null ? { assigned_agent: step.assigned_agent } : {}), + ...(step.note != null ? { note: step.note } : {}), })), }) ) } +export const renderModelPlanSuccess = ( + plan: AgentGateway.DeepAgentPlanController.PlanDoc, + version: number, + summary = "", +): string => + AgentGateway.DeepAgentPlanController.renderPlanWriteContext(plan, version) + + summary + + "\n\nCopyable parameters for the next plan update:\n" + + JSON.stringify(modelAdvanceParameters(plan, version)) + export const renderPlanRetryBase = ( previous: ReturnType, ref: ReturnType, @@ -511,15 +538,18 @@ export const renderPlanRetryBase = ( } return ( "\n\nAuthoritative plan parameters (copy expected_* and step_id values exactly; do not infer them):\n" + - JSON.stringify({ - expected_plan_id: previous.plan_id, - expected_version: ref.version, - active_step_id: previous.active_step_id, - steps: previous.steps.map((step) => ({ - step_id: step.step_id, - status: step.status, - ...(step.note != null ? { note: step.note } : {}), - })), - }) + JSON.stringify(modelAdvanceParameters(previous, ref.version)) ) } + +const modelAdvanceParameters = (plan: AgentGateway.DeepAgentPlanController.PlanDoc, version: number) => ({ + operation: "advance" as const, + expected_plan_id: plan.plan_id, + expected_version: version, + active_step_id: plan.active_step_id, + steps: plan.steps.map((step) => ({ + step_id: step.step_id, + status: step.status, + ...(step.note != null ? { note: step.note } : {}), + })), +}) diff --git a/packages/deepagent-code/src/tool/plan-write.txt b/packages/deepagent-code/src/tool/plan-write.txt index f5908c1e..1e6a56bb 100644 --- a/packages/deepagent-code/src/tool/plan-write.txt +++ b/packages/deepagent-code/src/tool/plan-write.txt @@ -17,7 +17,8 @@ Required protocol fields: - replan_reason: required and specific for replan; omit it for create/advance. Operation rules: -- create is only for a session with no plan. Omit all step IDs; the server assigns them once. +- create is only for a session with no plan. Omit all step IDs and active_step_id; the server rejects + either field when supplied, assigns every step ID once, and derives the active pointer from status. - advance is a status patch. Preserve the existing plan_id and exact version precondition. Step IDs must be copied exactly from the latest or plan result; never infer them from titles or array positions. Titles, acceptance criteria, assigned agents, goal, and assumptions are @@ -26,8 +27,9 @@ Operation rules: - replan is an intentional structural revision and must explain why. Copy an existing step_id only when that step keeps the same title, acceptance, and assigned agent; the correction payload shows those authoritative values, and omitted acceptance/assigned_agent fields are filled from it. Omit - step_id for every new step so the server allocates it. Omit active_step_id and use one active status - for server-side derivation. Omit assumptions to retain the current list; send [] only to clear it. + step_id for every new step so the server allocates it. Unknown supplied IDs are rejected. Omit + active_step_id; supplying it is rejected, and one active status drives server-side derivation. Omit + assumptions to retain the current list; send [] only to clear it. Do not use replan to bypass the version precondition or erase unresolved work. Suspicious regressions are rejected. - status must be pending, active, done, cancelled, or blocked. A blocked step must include a note. diff --git a/packages/deepagent-code/src/tool/semantic-fingerprint.ts b/packages/deepagent-code/src/tool/semantic-fingerprint.ts index b1527774..e2831a40 100644 --- a/packages/deepagent-code/src/tool/semantic-fingerprint.ts +++ b/packages/deepagent-code/src/tool/semantic-fingerprint.ts @@ -1,4 +1,6 @@ import type { Tool } from "ai" +import { Hash } from "@deepagent-code/core/util/hash" +import { ToolProvenance } from "./provenance" export type Resolver = (input: unknown) => unknown @@ -18,9 +20,51 @@ export function setResult(tool: Tool, resolver: (result: Result) => unkn resultResolvers.set(tool, (result) => resolver(result as Result)) } -export function resolveResult(tool: Tool | undefined, result: unknown) { +const readOnlyTools = new Set([ + "read", + "glob", + "grep", + "webfetch", + "websearch", + "code_intel", + "context_query", + "lsp", + "git_read", + "query_log", + "task_read", + "task_status", +]) + +export function resolveResult(tool: Tool | undefined, result: unknown, toolName?: string) { const resolver = tool && resultResolvers.get(tool) - return resolver?.(result) + if (resolver) return resolver(result) + if (!tool || (!readOnlyTools.has(toolName ?? "") && ToolProvenance.get(tool)?.riskTier !== "read_only")) return + const encoded = canonicalResult(result) + if (encoded === undefined) return + return { + kind: "read_only_result", + fingerprint: Hash.sha256(encoded), + bytes: new TextEncoder().encode(encoded).byteLength, + } +} + +function canonicalResult(value: unknown, ancestors = new Set()): string | undefined { + if (value === null || value === undefined) return "null" + if (typeof value === "bigint") return `{"$bigint":${JSON.stringify(value.toString())}}` + if (typeof value !== "object") return JSON.stringify(value) ?? "null" + if (ancestors.has(value)) return + ancestors.add(value) + const values = Array.isArray(value) + ? Array.from(value, (item) => canonicalResult(item, ancestors)) + : Object.keys(value) + .sort() + .map((key) => { + const encoded = canonicalResult((value as Record)[key], ancestors) + return encoded === undefined ? undefined : `${JSON.stringify(key)}:${encoded}` + }) + ancestors.delete(value) + if (values.some((item) => item === undefined)) return + return Array.isArray(value) ? `[${values.join(",")}]` : `{${values.join(",")}}` } export * as ToolSemanticFingerprint from "./semantic-fingerprint" diff --git a/packages/deepagent-code/src/tool/task.ts b/packages/deepagent-code/src/tool/task.ts index cdbb622d..047f975c 100644 --- a/packages/deepagent-code/src/tool/task.ts +++ b/packages/deepagent-code/src/tool/task.ts @@ -1355,7 +1355,7 @@ export const TaskTool = Tool.define( // L3a: Freeze mutation_capability at admission time (design §2.2.1) // L3b: Workspace preflight — automatic writers must reject dirty workspaces (design §3.2, §15.3.3) // ----------------------------------------------------------------------- - // BUG-001-405 Fix-D: separate capability classification from isolation policy. + // BUG-405-001 Fix-D: separate capability classification from isolation policy. // agentIsWriteCapable — does the agent's permission ruleset allow file mutation? // Drives mutation_capability in the DB and the preflight dirty-workspace check. // diff --git a/packages/deepagent-code/test/script/live-llm-activity-progress-oracle.test.ts b/packages/deepagent-code/test/script/live-llm-activity-progress-oracle.test.ts new file mode 100644 index 00000000..44b9b80d --- /dev/null +++ b/packages/deepagent-code/test/script/live-llm-activity-progress-oracle.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test" +import { assertActivityProgressObservation } from "../../script/live-llm/activity-progress-oracle" + +const triggerText = "Read the fixtures in order" +const steerText = "Include MARKER exactly once" + +describe("activity progress live oracle", () => { + test("accepts one trigger and steer with contiguous progress-to-final durability", () => { + expect(() => + assertActivityProgressObservation({ + caseName: "activity", + triggerText, + steerText, + marker: "MARKER", + expectedTools: ["read", "read"], + observation: observation(), + }), + ).not.toThrow() + }) + + test("rejects a text sibling without its durable progress marker", () => { + const value = observation() + Reflect.deleteProperty(value.durability.activityTextParts[1]!.data, "metadata") + expect(() => + assertActivityProgressObservation({ + caseName: "missing-marker", + triggerText, + steerText, + marker: "MARKER", + expectedTools: ["read", "read"], + observation: value, + }), + ).toThrow("lacked the durable progress marker") + }) +}) + +function observation() { + return { + users: [{ text: triggerText }, { text: steerText }], + steering: [ + { + delivery: "steer", + activeBeforeAdmission: true, + pendingAfterAdmission: true, + consumedAfterAdmission: true, + }, + ], + assistantTurns: 3, + finalText: "done MARKER", + newTools: [ + { name: "read", status: "completed" }, + { name: "read", status: "completed" }, + ], + providerErrors: [], + durability: { + activityAdmissions: [ + { admission_id: "admission_turn", delivery: "turn", admitted_message_id: "user_turn" }, + { admission_id: "admission_steer", delivery: "steer", admitted_message_id: "user_steer" }, + ], + legacyActivities: [ + { + activity_id: "activity_1", + owner_token: "123:owner", + state: "settled", + terminal_reason: "stop", + }, + ], + legacyActivityAdmissions: [ + { + activity_id: "activity_1", + admission_id: "admission_turn", + ordinal: 0, + role: "trigger", + }, + { + activity_id: "activity_1", + admission_id: "admission_steer", + ordinal: 1, + role: "steer", + }, + ], + activityProgress: [0, 1, 2].map((revision) => ({ + activity_id: "activity_1", + revision, + assistant_message_id: `assistant_${revision}`, + provider_receipt_id: `receipt_${revision}`, + state: revision === 2 ? "final" : "progress", + })), + activityTextParts: [0, 1, 2].flatMap((revision) => + ["first", "second"].map((text, index) => ({ + id: `part_${revision}_${index}`, + message_id: `assistant_${revision}`, + data: { + type: "text", + text, + metadata: { + deepagent_activity_progress: { + activity_id: "activity_1", + revision, + state: revision === 2 ? "final" : "progress", + }, + }, + }, + })), + ), + requestReceipts: [0, 1, 2].map((revision) => ({ + receipt_id: `receipt_${revision}`, + request_state: "dispatched", + })), + }, + } +} diff --git a/packages/deepagent-code/test/script/live-llm-plan-create-replan-oracle.test.ts b/packages/deepagent-code/test/script/live-llm-plan-create-replan-oracle.test.ts new file mode 100644 index 00000000..5c34becf --- /dev/null +++ b/packages/deepagent-code/test/script/live-llm-plan-create-replan-oracle.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, test } from "bun:test" +import { + assertPlanCreateObservation, + assertPlanReplanObservation, +} from "../../script/live-llm/plan-create-replan-oracle" + +const goal = "Preserve Plan authority" +const assumptions = ["server assigns IDs", "retained identity is authoritative"] +const created = { + plan_id: "plan_created", + goal, + assumptions, + active_step_id: "step_server_1", + steps: [ + { + step_id: "step_server_1", + title: "Inspect authority", + status: "active", + acceptance: null, + assigned_agent: null, + note: null, + }, + { + step_id: "step_server_2", + title: "Retain identity", + status: "pending", + acceptance: "hidden acceptance", + assigned_agent: "researcher", + note: null, + }, + ], +} as const + +describe("Plan create/replan live oracle", () => { + test("accepts server-allocated create IDs and an ID-less new replan step", () => { + expect(() => + assertPlanCreateObservation({ + caseName: "create", + observation: createObservation(), + goal, + assumptions, + steps: [ + { title: "Inspect authority", status: "active" }, + { title: "Retain identity", status: "pending" }, + ], + }), + ).not.toThrow() + expect(() => + assertPlanReplanObservation({ + caseName: "replan", + observation: replanObservation(), + authority: created, + expectedVersion: 2, + expectedActiveTitle: "Retain identity", + expectedReason: "add verification", + expectedStatuses: { + "Inspect authority": "done", + "Retain identity": "active", + "Verify allocation": "pending", + }, + expectedNewTitles: ["Verify allocation"], + expectedCalls: [{ version: 1, protocol: "success" }], + }), + ).not.toThrow() + }) + + test("rejects a model-supplied ID for a new replan step", () => { + const value = replanObservation() + Object.assign(value.newTools[0]!.input.steps[2]!, { step_id: "model_chosen" }) + expect(() => + assertPlanReplanObservation({ + caseName: "invented-id", + observation: value, + authority: created, + expectedVersion: 2, + expectedActiveTitle: "Retain identity", + expectedReason: "add verification", + expectedStatuses: { + "Inspect authority": "done", + "Retain identity": "active", + "Verify allocation": "pending", + }, + expectedNewTitles: ["Verify allocation"], + expectedCalls: [{ version: 1, protocol: "success" }], + }), + ).toThrow("supplied an ID for new step") + }) +}) + +function createObservation() { + return { + newTools: [ + { + messageID: "assistant_create", + id: "call_create", + name: "plan", + status: "completed", + input: { + operation: "create", + expected_plan_id: null, + expected_version: null, + goal, + assumptions, + steps: [ + { title: "Inspect authority", status: "active" }, + { title: "Retain identity", status: "pending" }, + ], + }, + metadata: { plan_protocol: "success" }, + }, + ], + plan: { document: created, ref: { id: "doc_create", version: 1 } }, + durability: receipts("assistant_create", "call_create", "semantic_valid"), + } +} + +function replanObservation() { + return { + newTools: [ + { + messageID: "assistant_replan", + id: "call_replan", + name: "plan", + status: "completed", + input: { + operation: "replan", + expected_plan_id: created.plan_id, + expected_version: 1, + replan_reason: "add verification", + goal, + steps: [ + { step_id: "step_server_1", status: "done" }, + { step_id: "step_server_2", status: "active" }, + { title: "Verify allocation", status: "pending" }, + ], + }, + metadata: { plan_protocol: "success" }, + }, + ], + plan: { + document: { + ...created, + active_step_id: "step_server_2", + steps: [ + { ...created.steps[0], status: "done" }, + { ...created.steps[1], status: "active" }, + { + step_id: "step_server_3", + title: "Verify allocation", + status: "pending", + acceptance: null, + assigned_agent: null, + note: null, + }, + ], + }, + ref: { id: "doc_replan", version: 2 }, + }, + durability: receipts("assistant_replan", "call_replan", "semantic_valid"), + } +} + +function receipts(messageID: string, callID: string, validationOutcome: string) { + return { + requestReceipts: [ + { + receipt_id: `receipt_${callID}`, + assistant_message_id: messageID, + request_state: "dispatched", + final_offered_tool_ids: ["plan"], + call_ids: [callID], + tool_definition_hash: "definition_hash", + }, + ], + argumentReceipts: [ + receipt("ai_sdk_input", "schema_valid", "payload_hash", callID), + receipt("adapter_assembly", "schema_valid", "payload_hash", callID), + receipt("processor_decoded", validationOutcome, "payload_hash", callID), + receipt("raw_frame", "not_evaluated", null, callID), + ], + } +} + +function receipt(layer: string, validationOutcome: string, payloadHash: string | null, callID: string) { + return { + receipt_id: `receipt_${callID}`, + layer, + call_id: layer === "raw_frame" ? null : callID, + tool_name: layer === "raw_frame" ? null : "plan", + event_type: layer === "adapter_assembly" ? "tool-call" : layer, + payload_hash: payloadHash, + payload_length: payloadHash ? 120 : null, + payload_keys: payloadHash ? ["expected_plan_id", "expected_version", "operation", "steps"] : [], + unavailable_reason: payloadHash ? null : "provider_transport_did_not_expose_raw_frame", + validation_outcome: validationOutcome, + } +} diff --git a/packages/deepagent-code/test/script/live-llm-routes.test.ts b/packages/deepagent-code/test/script/live-llm-routes.test.ts index 3cc5f1f9..a3f134ab 100644 --- a/packages/deepagent-code/test/script/live-llm-routes.test.ts +++ b/packages/deepagent-code/test/script/live-llm-routes.test.ts @@ -151,22 +151,26 @@ describe("live LLM route manifest", () => { "ext:legacy-session:subagent-resume", "ext:legacy-session:subagent-takeover", "ext:legacy-session:subagent-worktree-routing", + "ext:renderer-ui:activity-progress-package", "ext:v4-event-runtime:v4-multi-agent-runtime", "live:adapter:provider-smoke", "live:adapter:structured-output", "live:cli-subprocess:cli-headless", + "live:legacy-session:activity-progress-lifecycle", "live:legacy-session:bash-repair", "live:legacy-session:continuation-repetition", "live:legacy-session:degeneration", "live:legacy-session:file-mutations", "live:legacy-session:file-read-search", "live:legacy-session:plan-advance-contract", + "live:legacy-session:plan-create-replan-contract", "live:legacy-session:shell-exit-contract", "live:legacy-session:stale-validation", "live:legacy-session:steer-boundary", "live:legacy-session:structured-output", "live:legacy-session:subagent-control-plane", "live:legacy-session:subagent-foreground", + "live:packaged-sidecar:activity-progress-restart", "live:session-v2:bash-repair", "live:session-v2:file-mutations", "live:session-v2:file-read-search", @@ -181,6 +185,14 @@ describe("live LLM route manifest", () => { path: "packages/desktop/scripts/live-llm/packaged-sidecar.ts", runs: ["ext:packaged-sidecar:packaged-sidecar"], }, + { + path: "packages/desktop/scripts/live-llm/activity-progress-restart.ts", + runs: ["live:packaged-sidecar:activity-progress-restart"], + }, + { + path: "packages/desktop/scripts/live-llm/activity-progress-package.ts", + runs: ["ext:renderer-ui:activity-progress-package"], + }, { path: "packages/desktop/scripts/live-llm/desktop-subagents.ts", runs: [], @@ -295,6 +307,73 @@ describe("live LLM route manifest", () => { } }) + test("routes Plan create/replan authority changes to the DeepSeek real Provider suite", () => { + for (const path of [ + "packages/core/src/deepagent/plan-controller.ts", + "packages/deepagent-code/src/tool/plan-write.ts", + "packages/deepagent-code/src/tool/plan-write.txt", + "packages/deepagent-code/script/live-llm/plan-create-replan-contract.ts", + "packages/deepagent-code/script/live-llm/plan-create-replan-oracle.ts", + ]) { + const run = selectRoutes([path]).runs.find( + (item) => modelRunKey(item) === "live:legacy-session:plan-create-replan-contract", + ) + expect(run).toBeDefined() + expect(commandForModelRun(run!)).toEqual({ + cwd: "packages/deepagent-code", + args: ["bun", "run", "test:llm-live:plan-create-replan"], + }) + } + }) + + test("routes activity progress lifecycle changes to the DeepSeek real Provider suite", () => { + for (const path of [ + "packages/app/src/pages/session/message-timeline.data.ts", + "packages/deepagent-code/src/session/activity-sql.ts", + "packages/deepagent-code/src/session/prompt-intent.ts", + "packages/deepagent-code/src/session/prompt.ts", + "packages/deepagent-code/src/session/steer.ts", + "packages/deepagent-code/script/live-llm/activity-progress-lifecycle.ts", + "packages/deepagent-code/script/live-llm/activity-progress-oracle.ts", + ]) { + const run = selectRoutes([path]).runs.find( + (item) => modelRunKey(item) === "live:legacy-session:activity-progress-lifecycle", + ) + expect(run).toBeDefined() + expect(commandForModelRun(run!)).toEqual({ + cwd: "packages/deepagent-code", + args: ["bun", "run", "test:llm-live:activity-progress"], + }) + } + }) + + test("routes activity progress production changes through restart and packaged GUI release gates", () => { + const selected = selectRoutes([ + "packages/app/src/pages/session/message-timeline.data.ts", + "packages/deepagent-code/src/session/prompt-intent.ts", + "packages/deepagent-code/src/session/prompt.ts", + ]) + + expect(selected.runs.map(modelRunKey)).toContain("live:packaged-sidecar:activity-progress-restart") + expect(selected.runs.map(modelRunKey)).toContain("ext:renderer-ui:activity-progress-package") + expect( + commandForModelRun( + selected.runs.find((run) => modelRunKey(run) === "live:packaged-sidecar:activity-progress-restart")!, + ), + ).toEqual({ + cwd: "packages/desktop", + args: ["bun", "run", "test:llm-live:activity-progress-restart"], + }) + expect( + commandForModelRun( + selected.runs.find((run) => modelRunKey(run) === "ext:renderer-ui:activity-progress-package")!, + ), + ).toEqual({ + cwd: "packages/desktop", + args: ["bun", "run", "test:llm-release:activity-progress-package"], + }) + }) + test("keeps bounded takeover reachable from its harness and supervision seams", () => { const paths = [ "packages/deepagent-code/script/live-llm/subagent-takeover.ts", diff --git a/packages/deepagent-code/test/script/models-data.test.ts b/packages/deepagent-code/test/script/models-data.test.ts index d7c038b6..0ccc79ce 100644 --- a/packages/deepagent-code/test/script/models-data.test.ts +++ b/packages/deepagent-code/test/script/models-data.test.ts @@ -45,7 +45,6 @@ describe("models.dev build data", () => { const result = await loadModelsData({ environment: { DEEPAGENT_CODE_MODELS_URL: server.url.origin }, cacheFile, - fallbackFiles: [], }).finally(() => server.stop(true)) expect(result.source).toBe(`${server.url.origin}/api.json`) @@ -53,24 +52,6 @@ describe("models.dev build data", () => { expect(await Bun.file(cacheFile).json()).toEqual(catalog) }) - test("falls back to the first valid local snapshot when the network is unavailable", async () => { - await using directory = await fixture() - const invalid = path.join(directory.root, "invalid.json") - const fallback = path.join(directory.root, "fallback.json") - await Bun.write(invalid, "{}") - await Bun.write(fallback, JSON.stringify(catalog)) - - const result = await loadModelsData({ - environment: { DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1" }, - cacheFile: path.join(directory.root, "missing-cache.json"), - fallbackFiles: [invalid, fallback], - requestTimeoutMs: 200, - }) - - expect(result.source).toBe(fallback) - expect(JSON.parse(result.data)).toEqual(catalog) - }) - test("rejects an invalid explicitly configured snapshot instead of silently changing sources", async () => { await using directory = await fixture() const file = path.join(directory.root, "invalid.json") @@ -81,7 +62,7 @@ describe("models.dev build data", () => { ) }) - test("does not read or rewrite a builder-local cache when the network is unavailable", async () => { + test("fails closed when models.dev is unavailable", async () => { await using directory = await fixture() const cacheFile = path.join(directory.root, "models.json") const builderOnly = { @@ -93,14 +74,30 @@ describe("models.dev build data", () => { } await Bun.write(cacheFile, JSON.stringify(builderOnly)) - const result = await loadModelsData({ - environment: { DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1" }, - cacheFile, - requestTimeoutMs: 200, - }) - - expect(result.source).toBe(path.resolve(import.meta.dir, "../tool/fixtures/models-api.json")) - expect(JSON.parse(result.data)["builder-only"]).toBeUndefined() + await expect( + loadModelsData({ + environment: { DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1", DEEPAGENT_CODE_CHANNEL: "dev" }, + cacheFile, + requestTimeoutMs: 200, + }), + ).rejects.toThrow("refusing to use a local snapshot") expect(await Bun.file(cacheFile).json()).toEqual(builderOnly) }) + + test("does not allow a snapshot to override a production build", async () => { + await using directory = await fixture() + const file = path.join(directory.root, "configured.json") + await Bun.write(file, JSON.stringify(catalog)) + + await expect( + loadModelsData({ + environment: { + MODELS_DEV_API_JSON: file, + DEEPAGENT_CODE_CHANNEL: "prod", + DEEPAGENT_CODE_MODELS_URL: "http://127.0.0.1:1", + }, + requestTimeoutMs: 200, + }), + ).rejects.toThrow("MODELS_DEV_API_JSON is not allowed for production builds") + }) }) diff --git a/packages/deepagent-code/test/server/httpapi-provider.test.ts b/packages/deepagent-code/test/server/httpapi-provider.test.ts index 172cf88c..a94850e2 100644 --- a/packages/deepagent-code/test/server/httpapi-provider.test.ts +++ b/packages/deepagent-code/test/server/httpapi-provider.test.ts @@ -263,37 +263,6 @@ function setEnvScoped(key: string, value: string) { } describe("provider HttpApi", () => { - it.instance( - "only autoloads the public hosted provider for a blank installation", - Effect.gen(function* () { - const directory = (yield* TestInstance).directory - yield* setEnvScoped("DEEPAGENT_CODE_AUTH_CONTENT", "{}") - - const response = yield* request("/provider", { - headers: { "x-deepagent-code-directory": directory }, - }) - expect(response.status).toBe(200) - - const body = yield* response.json - expect(providerList(body, "all").length).toBeGreaterThan(0) - expect(isRecord(body) && body.connected).toEqual(["deepagent-code"]) - expect(isRecord(body) && isRecord(body.default) && Object.keys(body.default).length).toBeGreaterThan(0) - - const hosted = providerByID(body, "all", "deepagent-code") - expect(hosted).toBeDefined() - expect(isRecord(hosted) && isRecord(hosted.models) && Object.keys(hosted.models).length).toBeGreaterThan(0) - expect( - isRecord(hosted) && - isRecord(hosted.models) && - Object.values(hosted.models).every( - (model) => isRecord(model) && isRecord(model.cost) && model.cost.input === 0, - ), - ).toBe(true) - }), - projectOptions, - 30000, - ) - it.instance.skip( "returns public v2 provider not found errors", Effect.gen(function* () { diff --git a/packages/deepagent-code/test/session/compaction.test.ts b/packages/deepagent-code/test/session/compaction.test.ts index 70b01919..d4383287 100644 --- a/packages/deepagent-code/test/session/compaction.test.ts +++ b/packages/deepagent-code/test/session/compaction.test.ts @@ -3,7 +3,7 @@ import { ConfigV1 } from "@deepagent-code/core/v1/config/config" import { SessionV1 } from "@deepagent-code/core/v1/session" import { Database } from "@deepagent-code/core/database/database" import { EventTable } from "@deepagent-code/core/event/sql" -import { PartTable, SessionWorldStateBaselineTable } from "@deepagent-code/core/session/sql" +import { PartTable, SessionHistoryStateTable, SessionWorldStateBaselineTable } from "@deepagent-code/core/session/sql" import { EventV2Bridge } from "@/event-v2-bridge" import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" @@ -38,6 +38,7 @@ import { TestConfig } from "../fixture/config" import { RuntimeFlags } from "@/effect/runtime-flags" import { PromptEpoch } from "@/session/prompt-epoch" import { CompactionArtifactTable, CompactionRunTable, CompactionSummaryAttemptTable } from "@/session/compaction-sql" +import { SessionPromptEpochTable } from "@/session/prompt-epoch.sql" import { LLMEvent, Usage } from "@deepagent-code/llm" import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" @@ -609,7 +610,9 @@ describe("session.compaction.create", () => { expect(msgs).toHaveLength(1) expect(msgs[0].info.role).toBe("user") expect( - SessionProcessorModule.planProtocolActivityID(msgs[0].info.role === "user" ? msgs[0].info.metadata : undefined), + SessionProcessorModule.planProtocolActivityID( + msgs[0].info.role === "user" ? msgs[0].info.metadata : undefined, + ), ).toBe(activityID) expect(msgs[0].parts).toHaveLength(1) expect(msgs[0].parts[0]).toMatchObject({ @@ -977,6 +980,32 @@ describe("session.compaction.process", () => { expect(summary.info.finish).toBe("error") expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") } + const { db } = yield* Database.Service + expect( + yield* db + .select({ state: CompactionRunTable.state, failure: CompactionRunTable.terminal_failure_kind }) + .from(CompactionRunTable) + .where(eq(CompactionRunTable.session_id, session.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ state: "failed", failure: "summary_context_overflow" }) + expect( + yield* db + .select({ state: SessionHistoryStateTable.state }) + .from(SessionHistoryStateTable) + .where(eq(SessionHistoryStateTable.session_id, session.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ state: "recovery_required" }) + expect( + yield* db + .select({ authority: SessionPromptEpochTable.authority_state }) + .from(SessionPromptEpochTable) + .where(and(eq(SessionPromptEpochTable.session_id, session.id), eq(SessionPromptEpochTable.state, "active"))) + .get() + .pipe(Effect.orDie), + ).toEqual({ authority: "recovery_required" }) + expect(Exit.isFailure(yield* ssn.assertRunnable(session.id).pipe(Effect.exit))).toBe(true) }).pipe(withCompaction({ result: "compact" })), ) diff --git a/packages/deepagent-code/test/session/message-v2.test.ts b/packages/deepagent-code/test/session/message-v2.test.ts index b97f751b..99e32287 100644 --- a/packages/deepagent-code/test/session/message-v2.test.ts +++ b/packages/deepagent-code/test/session/message-v2.test.ts @@ -1081,18 +1081,90 @@ describe("session.message-v2.toModelMessage", () => { ] expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "partial answer" }], + }, + ]) + }) + + test("replays activity progress only while its tool continuation is active", async () => { + const assistantID = "msg_002" + const progress = { + ...basePart(assistantID, "p1"), + type: "text" as const, + text: "continuing implementation", + metadata: { + deepagent_activity_progress: { + activity_id: "activity-1", + revision: 1, + state: "progress", + }, + }, + } + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "msg_001"), + parts: [ + progress, + { ...basePart(assistantID, "p2"), type: "text", text: "tool-call lead-in" }, + ] as SessionV1.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, model, { + terminalBoundaryID: MessageID.make("msg_001"), + }), + ).toStrictEqual([ { role: "assistant", content: [ - { type: "reasoning", text: "thinking", providerOptions: undefined }, - { type: "text", text: "partial answer" }, + { type: "text", text: "continuing implementation" }, + { type: "text", text: "tool-call lead-in" }, + ], + }, + ]) + expect( + await MessageV2.toModelMessages(input, model, { + terminalBoundaryID: MessageID.make(assistantID), + }), + ).toStrictEqual([]) + expect( + await MessageV2.toModelMessages( + [ + { + info: input[0].info, + parts: [ + { + ...progress, + metadata: { + deepagent_activity_progress: { + ...progress.metadata.deepagent_activity_progress, + state: "final", + }, + }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "tool-call lead-in" }, + ] as SessionV1.Part[], + }, + ], + model, + { terminalBoundaryID: MessageID.make(assistantID) }, + ), + ).toStrictEqual([ + { + role: "assistant", + content: [ + { type: "text", text: "continuing implementation" }, + { type: "text", text: "tool-call lead-in" }, ], }, ]) }) test("preserves OpenRouter reasoning details through provider transform", async () => { - const assistantID = "m-assistant" + const assistantID = "msg_002" const openrouterModel: Provider.Model = { ...model, id: ModelV2.ID.make("deepseek/deepseek-v4-pro"), @@ -1144,7 +1216,13 @@ describe("session.message-v2.toModelMessage", () => { ] expect( - ProviderTransform.message(await MessageV2.toModelMessages(input, openrouterModel), openrouterModel, {}), + ProviderTransform.message( + await MessageV2.toModelMessages(input, openrouterModel, { + terminalBoundaryID: MessageID.make("msg_000"), + }), + openrouterModel, + {}, + ), ).toStrictEqual([ { role: "assistant", @@ -1164,7 +1242,67 @@ describe("session.message-v2.toModelMessage", () => { ]) }) - test("preserves settled same-model reasoning so a new user turn keeps the cached prefix", async () => { + test("replays metadata-free interleaved reasoning only for an active tool continuation", async () => { + const assistantID = "msg_002" + const deepseekModel: Provider.Model = { + ...model, + id: ModelV2.ID.make("deepseek-v4-pro"), + providerID: ProviderV2.ID.make("deepseek"), + api: { + id: "deepseek-v4-pro", + url: "https://api.deepseek.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + capabilities: { + ...model.capabilities, + reasoning: true, + interleaved: { field: "reasoning_content" }, + }, + } + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "msg_001", undefined, { + providerID: deepseekModel.providerID, + modelID: deepseekModel.id, + }), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "ordinary reasoning", + time: { start: 0, end: 1 }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "working" }, + ] as SessionV1.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, deepseekModel, { + terminalBoundaryID: MessageID.make("msg_001"), + }), + ).toStrictEqual([ + { + role: "assistant", + content: [ + { type: "reasoning", text: "ordinary reasoning", providerOptions: undefined }, + { type: "text", text: "working" }, + ], + }, + ]) + expect( + await MessageV2.toModelMessages(input, deepseekModel, { + terminalBoundaryID: MessageID.make(assistantID), + }), + ).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "working" }], + }, + ]) + }) + + test("preserves settled signed Anthropic reasoning so a new user turn keeps the cached prefix", async () => { const anthropicModel: Provider.Model = { ...model, id: ModelV2.ID.make("kimi-k3"), @@ -1240,6 +1378,127 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("drops settled Anthropic reasoning without a valid signed prefix", async () => { + const assistantID = "msg_002" + const anthropicModel: Provider.Model = { + ...model, + id: ModelV2.ID.make("kimi-k3"), + providerID: ProviderV2.ID.make("kimi-for-coding"), + api: { + id: "kimi-k3", + url: "https://api.kimi.com/coding/v1", + npm: "@ai-sdk/anthropic", + }, + capabilities: { + ...model.capabilities, + reasoning: true, + }, + } + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "msg_001", undefined, { + providerID: anthropicModel.providerID, + modelID: anthropicModel.id, + }), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "unsigned reasoning", + time: { start: 0, end: 1 }, + metadata: { anthropic: { signature: " " } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "answer" }, + ] as SessionV1.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, anthropicModel, { + terminalBoundaryID: MessageID.make(assistantID), + }), + ).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + }, + ]) + }) + + test("preserves settled encrypted OpenAI reasoning state", async () => { + const assistantID = "msg_002" + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "msg_001"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "reasoning summary", + time: { start: 0, end: 1 }, + metadata: { openai: { reasoningEncryptedContent: "encrypted-state" } }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "answer" }, + ] as SessionV1.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, model, { + terminalBoundaryID: MessageID.make(assistantID), + }), + ).toStrictEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "reasoning summary", + providerOptions: { openai: { reasoningEncryptedContent: "encrypted-state" } }, + }, + { type: "text", text: "answer" }, + ], + }, + ]) + }) + + test("requires encrypted state for OpenAI even when interleaved reasoning is enabled", async () => { + const assistantID = "msg_002" + const interleavedOpenAI: Provider.Model = { + ...model, + capabilities: { + ...model.capabilities, + reasoning: true, + interleaved: { field: "reasoning_content" }, + }, + } + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo(assistantID, "msg_001"), + parts: [ + { + ...basePart(assistantID, "p1"), + type: "reasoning", + text: "reasoning without encrypted state", + time: { start: 0, end: 1 }, + }, + { ...basePart(assistantID, "p2"), type: "text", text: "answer" }, + ] as SessionV1.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, interleavedOpenAI, { + terminalBoundaryID: MessageID.make("msg_001"), + }), + ).toStrictEqual([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + }, + ]) + }) + test("still drops settled reasoning when projecting history to a different model", async () => { const assistantID = "msg_002" const input: SessionV1.WithParts[] = [ @@ -1418,6 +1677,14 @@ describe("session.message-v2.toModelMessage", () => { test("substitutes space for empty text between signed reasoning blocks", async () => { // Reproduces the bug pattern: [reasoning(sig), text(""), reasoning(sig), text(full)] const assistantID = "m-assistant" + const anthropicModel: Provider.Model = { + ...model, + api: { + id: model.api.id, + url: model.api.url, + npm: "@ai-sdk/anthropic", + }, + } const input: SessionV1.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), @@ -1442,7 +1709,7 @@ describe("session.message-v2.toModelMessage", () => { }, ] - const result = await MessageV2.toModelMessages(input, model) + const result = await MessageV2.toModelMessages(input, anthropicModel) // step-start splits into two assistant messages; SDK's groupIntoBlocks merges them later expect(result).toHaveLength(2) @@ -1454,6 +1721,14 @@ describe("session.message-v2.toModelMessage", () => { // Bedrock signed reasoning is preserved as reasoning metadata, but unlike the // direct Anthropic path we do not preserve empty text separators for Bedrock. const assistantID = "m-assistant-bedrock" + const bedrockModel: Provider.Model = { + ...model, + api: { + id: model.api.id, + url: model.api.url, + npm: "@ai-sdk/amazon-bedrock", + }, + } const input: SessionV1.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), @@ -1470,9 +1745,10 @@ describe("session.message-v2.toModelMessage", () => { }, ] - const result = await MessageV2.toModelMessages(input, model) + const result = await MessageV2.toModelMessages(input, bedrockModel) expect(result).toHaveLength(1) + expect((result[0].content as any[]).some((part) => part.type === "reasoning")).toBeTrue() const texts = (result[0].content as any[]).filter((p) => p.type === "text") expect(texts.map((t) => t.text)).toStrictEqual(["", "answer"]) }) diff --git a/packages/deepagent-code/test/session/prompt-intent.test.ts b/packages/deepagent-code/test/session/prompt-intent.test.ts index 8de745b3..71897900 100644 --- a/packages/deepagent-code/test/session/prompt-intent.test.ts +++ b/packages/deepagent-code/test/session/prompt-intent.test.ts @@ -5,13 +5,26 @@ import { ProjectTable } from "@deepagent-code/core/project/sql" import { ModelV2 } from "@deepagent-code/core/model" import { ProviderV2 } from "@deepagent-code/core/provider" import { AbsolutePath } from "@deepagent-code/core/schema" -import { MessageTable, PartTable, SessionIntentTable, SessionTable } from "@deepagent-code/core/session/sql" +import { + MessageTable, + PartTable, + SessionInputTable, + SessionIntentTable, + SessionTable, +} from "@deepagent-code/core/session/sql" import { SessionV1 } from "@deepagent-code/core/v1/session" import { eq } from "drizzle-orm" import { Effect } from "effect" import { SessionMutationEpoch } from "../../src/session/mutation-epoch" import { SessionPromptIntent } from "../../src/session/prompt-intent" +import { + SessionActivityAdmissionTable, + SessionActivityProgressTable, + SessionLegacyActivityAdmissionTable, + SessionLegacyActivityTable, +} from "../../src/session/activity-sql" import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionToolRequestReceiptTable } from "../../src/session/tool-request-receipt.sql" import { testEffect } from "../lib/effect" const database = Database.layerFromPath(":memory:") @@ -180,9 +193,270 @@ describe("SessionPromptIntent", () => { const retry = yield* claim({ intentID: "intent_atomic", messageID: MessageID.make("msg_atomic_retry") }) expect(retry.kind).toBe("admitted") expect(retry.receipt.messageID).toBe(first.receipt.messageID) + expect(yield* db.select().from(SessionActivityAdmissionTable).all().pipe(Effect.orDie)).toHaveLength(1) + expect(yield* db.select().from(SessionLegacyActivityTable).all().pipe(Effect.orDie)).toHaveLength(1) + expect(yield* db.select().from(SessionLegacyActivityAdmissionTable).all().pipe(Effect.orDie)).toMatchObject([ + { ordinal: 0, role: "trigger" }, + ]) + expect( + (yield* db.select().from(SessionInputTable).all().pipe(Effect.orDie)).filter( + (row) => String(row.id) === String(first.receipt.messageID), + ), + ).toHaveLength(0) + }), + ) + + it.effect("terminal provider receipts settle provisional progress deterministically after restart", () => + Effect.gen(function* () { + yield* setup + const first = yield* claim({ intentID: "intent_progress", messageID: MessageID.make("msg_progress_user") }) + expect(first.kind).toBe("claimed") + if (first.kind !== "claimed") return + yield* SessionPromptIntent.materializeTurn({ + receipt: first.receipt, + message: message(first.receipt.messageID), + }) + const activity = yield* SessionPromptIntent.activityForMessage({ + sessionID, + messageID: first.receipt.messageID, + }) + expect(activity?.state).toBe("active") + if (!activity) return + const { db } = yield* Database.Service + const assistantID = MessageID.make("msg_progress_assistant") + yield* db + .insert(MessageTable) + .values({ + id: assistantID, + session_id: sessionID, + time_created: 2, + data: { + role: "assistant", + parentID: first.receipt.messageID, + mode: "build", + agent: "build", + path: { cwd: "/project", root: "/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + time: { created: 2, completed: 3 }, + finish: "stop", + } as typeof MessageTable.$inferInsert.data, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PartTable) + .values([ + { + id: PartID.make("prt_progress_preamble"), + message_id: assistantID, + session_id: sessionID, + time_created: 2, + data: { type: "text", text: "final preamble" } as typeof PartTable.$inferInsert.data, + }, + { + id: PartID.make("prt_progress_final"), + message_id: assistantID, + session_id: sessionID, + time_created: 2, + data: { type: "text", text: "final answer" } as typeof PartTable.$inferInsert.data, + }, + ]) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionToolRequestReceiptTable) + .values({ + receipt_id: "receipt-progress-final", + request_ordinal: 1, + session_id: sessionID, + user_message_id: first.receipt.messageID, + assistant_message_id: assistantID, + provider_id: "test", + model_id: "test", + registry_tool_ids: [], + permission_filtered_tool_ids: [], + final_offered_tool_ids: [], + call_ids: [], + provider_state: "settled", + terminal_at: 3, + response_fingerprint: "response-final", + request_state: "dispatched", + created_at: 2, + }) + .run() + .pipe(Effect.orDie) + yield* SessionPromptIntent.beginProgress({ + activityID: activity.activityID, + assistantMessageID: assistantID, + providerReceiptID: "receipt-progress-final", + }) + + expect(yield* SessionPromptIntent.recoverActiveActivities()).toBe(0) + expect(yield* SessionPromptIntent.recoverActiveActivities("next-process-owner")).toBe(1) + expect( + yield* db + .select() + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.assistant_message_id, assistantID)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ + state: "final", + text_part_id: expect.stringMatching(/^prt_progress_/), + response_fingerprint: "response-final", + }) + expect( + yield* db + .select() + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.activity_id, activity.activityID)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ state: "settled", terminal_reason: "stop" }) + expect( + yield* db + .select({ data: PartTable.data }) + .from(PartTable) + .where(eq(PartTable.message_id, assistantID)) + .all() + .pipe(Effect.orDie), + ).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + metadata: { + deepagent_activity_progress: { + activity_id: activity.activityID, + revision: 0, + state: "final", + }, + }, + }), + }), + expect.objectContaining({ + data: expect.objectContaining({ + metadata: { + deepagent_activity_progress: { + activity_id: activity.activityID, + revision: 0, + state: "final", + }, + }, + }), + }), + ]) }), ) + it.effect( + "terminal tool progress becomes recovery-required after restart instead of leaving an orphan active owner", + () => + Effect.gen(function* () { + yield* setup + const first = yield* claim({ + intentID: "intent_progress_tool", + messageID: MessageID.make("msg_progress_tool_user"), + }) + expect(first.kind).toBe("claimed") + if (first.kind !== "claimed") return + yield* SessionPromptIntent.materializeTurn({ + receipt: first.receipt, + message: message(first.receipt.messageID), + }) + const activity = yield* SessionPromptIntent.activityForMessage({ + sessionID, + messageID: first.receipt.messageID, + }) + expect(activity?.state).toBe("active") + if (!activity) return + const { db } = yield* Database.Service + const assistantID = MessageID.make("msg_progress_tool_assistant") + yield* db + .insert(MessageTable) + .values({ + id: assistantID, + session_id: sessionID, + time_created: 2, + data: { + role: "assistant", + parentID: first.receipt.messageID, + mode: "build", + agent: "build", + path: { cwd: "/project", root: "/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + time: { created: 2, completed: 3 }, + finish: "tool-calls", + } as typeof MessageTable.$inferInsert.data, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(PartTable) + .values({ + id: PartID.make("prt_progress_tool_text"), + message_id: assistantID, + session_id: sessionID, + time_created: 2, + data: { type: "text", text: "working" } as typeof PartTable.$inferInsert.data, + }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionToolRequestReceiptTable) + .values({ + receipt_id: "receipt-progress-tool", + request_ordinal: 1, + session_id: sessionID, + user_message_id: first.receipt.messageID, + assistant_message_id: assistantID, + provider_id: "test", + model_id: "test", + registry_tool_ids: ["read"], + permission_filtered_tool_ids: ["read"], + final_offered_tool_ids: ["read"], + call_ids: ["call-progress-tool"], + provider_state: "settled", + terminal_at: 3, + response_fingerprint: "response-progress-tool", + request_state: "dispatched", + created_at: 2, + }) + .run() + .pipe(Effect.orDie) + yield* SessionPromptIntent.beginProgress({ + activityID: activity.activityID, + assistantMessageID: assistantID, + providerReceiptID: "receipt-progress-tool", + }) + + expect(yield* SessionPromptIntent.recoverActiveActivities("next-process-owner")).toBe(1) + expect( + yield* db + .select() + .from(SessionActivityProgressTable) + .where(eq(SessionActivityProgressTable.assistant_message_id, assistantID)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ state: "progress", finish_observed: "tool-calls" }) + expect( + yield* db + .select() + .from(SessionLegacyActivityTable) + .where(eq(SessionLegacyActivityTable.activity_id, activity.activityID)) + .get() + .pipe(Effect.orDie), + ).toMatchObject({ + state: "recovery_required", + terminal_reason: "process restarted after settled activity progress", + }) + }), + ) + it.effect("a revert epoch prevents an old direct request from materializing any message", () => Effect.gen(function* () { yield* setup diff --git a/packages/deepagent-code/test/session/prompt.test.ts b/packages/deepagent-code/test/session/prompt.test.ts index 9a6561b8..2ff6f425 100644 --- a/packages/deepagent-code/test/session/prompt.test.ts +++ b/packages/deepagent-code/test/session/prompt.test.ts @@ -30,6 +30,8 @@ import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" import { SessionHistoryStateTable, + SessionIntentTable, + SessionSteerTable, MessageTable, SessionMessageTable, SessionPromptEpochMessageTable, @@ -49,6 +51,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionV2 } from "@deepagent-code/core/session" import { SessionExecution } from "@deepagent-code/core/session/execution" +import { SessionMessage } from "@deepagent-code/core/session/message" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -69,7 +72,7 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { createHash } from "node:crypto" import { symlink } from "node:fs/promises" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" +import { raw, reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@deepagent-code/core/provider" import { ModelV2 } from "@deepagent-code/core/model" @@ -84,6 +87,11 @@ import { SessionPromptEpochTable } from "@/session/prompt-epoch.sql" import { SessionToolRequestReceiptTable } from "@/session/tool-request-receipt.sql" import { SessionToolArgumentReceiptTable } from "@/session/tool-argument-receipt.sql" import { CompactionArtifactTable, CompactionRunTable } from "@/session/compaction-sql" +import { + SessionActivityAdmissionTable, + SessionActivityProgressTable, + SessionLegacyActivityTable, +} from "@/session/activity-sql" void Log.init({ print: false }) @@ -2059,7 +2067,9 @@ it.instance("BUG-010 forward-compatible malformed plan stops before a third Prov }, firstState: "completed", protocol: "invalid", - errorCode: "empty_title", + // Model create payloads now fail closed on supplied identity before the + // lower-priority empty-title check. + errorCode: "unsafe_step_identity", validationOutcome: "semantic_invalid", }), ) @@ -2099,6 +2109,60 @@ it.instance("loop continues (not exits) when finish is length: injects a continu }), ) +it.instance("synthetic output continuation preserves the durable legacy activity owner", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const { db } = yield* Database.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.push( + raw({ + chunks: [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { role: "assistant" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: { content: "partial" } }], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ delta: {}, finish_reason: "length" }], + }, + ], + }), + ) + yield* llm.text("complete") + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "produce a long response" }], + }) + + expect(yield* llm.hits).toHaveLength(2) + expect( + yield* db + .select({ activityID: SessionActivityProgressTable.activity_id, state: SessionActivityProgressTable.state }) + .from(SessionActivityProgressTable) + .orderBy(SessionActivityProgressTable.revision) + .all() + .pipe(Effect.orDie), + ).toEqual([expect.objectContaining({ state: "progress" }), expect.objectContaining({ state: "final" })]) + expect(yield* db.select().from(SessionLegacyActivityTable).all().pipe(Effect.orDie)).toMatchObject([ + { state: "settled", terminal_reason: "stop" }, + ]) + }), +) + it.instance("loop retries truncated tool input with the bounded patch transaction guidance", () => Effect.gen(function* () { const { llm } = yield* useServerConfig(providerCfg) @@ -2778,33 +2842,30 @@ it.instance( yield* llm.wait(1) - const id = MessageID.ascending() - const b = yield* prompt - .prompt({ - sessionID: chat.id, - messageID: id, - agent: "build", - model: ref, - parts: [{ type: "text", text: "second" }], - }) - .pipe(Effect.forkChild) - - yield* pollWithTimeout( - sessions - .messages({ sessionID: chat.id }) - .pipe( - Effect.map((msgs) => - msgs.some((msg) => msg.info.role === "user" && msg.info.id === id) ? true : undefined, - ), - ), - "timed out waiting for second prompt to save", - ) + const receipt = yield* prompt.promptAsync({ + sessionID: chat.id, + messageID: MessageID.ascending(), + intentID: "intent_concurrent_prompt_steer", + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + expect(receipt.delivery).toBe("steer") + const { db } = yield* Database.Service + const durableMessageID = SessionMessage.ID.make(receipt.messageID) + expect( + yield* db + .select({ id: SessionSteerTable.id, consumedSeq: SessionSteerTable.consumed_seq }) + .from(SessionSteerTable) + .where(eq(SessionSteerTable.id, durableMessageID)) + .get() + .pipe(Effect.orDie), + ).toEqual({ id: durableMessageID, consumedSeq: null }) yield* Deferred.succeed(gate, void 0) - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + const ea = yield* Fiber.await(a) expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) expect(yield* llm.calls).toBe(2) const msgs = yield* sessions.messages({ sessionID: chat.id }) @@ -2812,7 +2873,7 @@ it.instance( expect(assistants).toHaveLength(2) const last = assistants.at(-1) if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") - expect(last.info.parentID).toBe(id) + expect(last.info.parentID).toBe(receipt.messageID) expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) const inputs = yield* llm.inputs @@ -3457,6 +3518,50 @@ noLLMServer.instance( // Missing file handling +noLLMServer.instance( + "direct prompts auto-claim one durable intent and reconcile exact retries", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const { db } = yield* Database.Service + const session = yield* sessions.create({}) + const messageID = MessageID.make("msg_direct_intent_retry") + const input = { + sessionID: session.id, + messageID, + agent: "build", + noReply: true, + parts: [{ type: "text" as const, text: "durable direct prompt" }], + } + + const first = yield* prompt.prompt(input) + const retry = yield* prompt.prompt(input) + const conflict = yield* prompt + .prompt({ + ...input, + parts: [{ type: "text", text: "conflicting retry" }], + }) + .pipe(Effect.exit) + + expect(retry).toEqual(first) + expect(Exit.isFailure(conflict)).toBe(true) + expect( + yield* db + .select() + .from(SessionIntentTable) + .where(eq(SessionIntentTable.admitted_message_id, messageID)) + .all() + .pipe(Effect.orDie), + ).toHaveLength(1) + expect(yield* db.select().from(SessionActivityAdmissionTable).all().pipe(Effect.orDie)).toHaveLength(1) + expect(yield* db.select().from(SessionLegacyActivityTable).all().pipe(Effect.orDie)).toMatchObject([ + { state: "active", terminal_reason: null }, + ]) + }), + { config: cfg }, +) + noLLMServer.instance( "task notification prompt retries reuse the persisted user message", () => @@ -3849,10 +3954,10 @@ noLLMServer.instance( Effect.gen(function* () { const prompt = yield* SessionPrompt.Service const sessions = yield* Session.Service - const session = yield* sessions.create({}) + const otherSession = yield* sessions.create({}) const other = yield* prompt.prompt({ - sessionID: session.id, + sessionID: otherSession.id, agent: "build", model: { providerID: ProviderV2.ID.make("deepagent-code"), modelID: ModelV2.ID.make("kimi-k2.5-free") }, noReply: true, @@ -3861,8 +3966,9 @@ noLLMServer.instance( if (other.info.role !== "user") throw new Error("expected user message") expect(other.info.model.variant).toBeUndefined() + const matchingSession = yield* sessions.create({}) const match = yield* prompt.prompt({ - sessionID: session.id, + sessionID: matchingSession.id, agent: "build", noReply: true, parts: [{ type: "text", text: "hello again" }], @@ -3875,8 +3981,9 @@ noLLMServer.instance( }) expect(match.info.model.variant).toBe("xhigh") + const overrideSession = yield* sessions.create({}) const override = yield* prompt.prompt({ - sessionID: session.id, + sessionID: overrideSession.id, agent: "build", noReply: true, variant: "high", @@ -3885,7 +3992,13 @@ noLLMServer.instance( if (override.info.role !== "user") throw new Error("expected user message") expect(override.info.model.variant).toBe("high") - yield* sessions.remove(session.id) + yield* Effect.forEach( + [otherSession, matchingSession, overrideSession], + (session) => sessions.remove(session.id), + { + discard: true, + }, + ) }), { config: { diff --git a/packages/deepagent-code/test/session/steer.test.ts b/packages/deepagent-code/test/session/steer.test.ts index 61f2c236..611bfc69 100644 --- a/packages/deepagent-code/test/session/steer.test.ts +++ b/packages/deepagent-code/test/session/steer.test.ts @@ -67,6 +67,7 @@ import { MessageTable, SessionIntentTable, SessionSteerTable, SessionTable } fro import { eq } from "drizzle-orm" import { SessionMutationEpoch } from "../../src/session/mutation-epoch" import { SessionPromptIntent } from "../../src/session/prompt-intent" +import { SessionActivityAdmissionTable, SessionLegacyActivityAdmissionTable } from "../../src/session/activity-sql" void Log.init({ print: false }) @@ -330,6 +331,42 @@ const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => const mkPrompt = (text: string): Prompt => Prompt.fromUserMessage({ text }) +const seedLegacyActivity = (sessionID: SessionID, suffix: string) => + Effect.gen(function* () { + const messageID = MessageID.make(`msg_${suffix}_trigger`) + const claim = yield* SessionPromptIntent.claim({ + intentID: `intent_${suffix}_trigger`, + sessionID, + source: "composer", + variant: "original", + payloadHash: `payload-${suffix}-trigger`, + messageID, + }) + if (claim.kind !== "claimed") return + yield* SessionPromptIntent.materializeTurn({ + receipt: claim.receipt, + message: { + info: { + id: messageID, + sessionID, + role: "user", + time: { created: 1 }, + agent: "build", + model: ref, + }, + parts: [ + { + id: PartID.make(`prt_${suffix}_trigger`), + messageID, + sessionID, + type: "text", + text: "trigger", + }, + ], + }, + }) + }) + // ── Unit: admit → pending (ordered) → markConsumed (consume-once) ─────────────────────────────────── off.instance( @@ -463,6 +500,7 @@ off.instance( const sessions = yield* Session.Service const { db } = yield* Database.Service const chat = yield* sessions.create({ title: "Atomic steer intent" }) + yield* seedLegacyActivity(chat.id, "atomic_steer") const messageID = MessageID.make("msg_atomic_steer_intent") const claim = yield* SessionPromptIntent.claim({ intentID: "intent_atomic_steer", @@ -491,6 +529,28 @@ off.instance( expect(intent?.state).toBe("admitted") expect(intent?.admitted_message_id).toBe(admitted.id) expect((yield* steer.pending(chat.id)).map((item) => item.id)).toEqual([admitted.id]) + const owner = yield* SessionPromptIntent.activityForMessage({ + sessionID: chat.id, + messageID: MessageID.make(admitted.id), + }) + expect(owner?.state).toBe("active") + if (!owner) return + expect( + yield* db + .select({ + activityID: SessionLegacyActivityAdmissionTable.activity_id, + role: SessionLegacyActivityAdmissionTable.role, + delivery: SessionActivityAdmissionTable.delivery, + }) + .from(SessionLegacyActivityAdmissionTable) + .innerJoin( + SessionActivityAdmissionTable, + eq(SessionActivityAdmissionTable.admission_id, SessionLegacyActivityAdmissionTable.admission_id), + ) + .where(eq(SessionActivityAdmissionTable.admitted_message_id, admitted.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ activityID: owner.activityID, role: "steer", delivery: "steer" }) yield* SessionPromptIntent.complete({ intentID: claim.receipt.intentID, ownerToken: claim.receipt.ownerToken, @@ -792,7 +852,8 @@ on.instance( expect((yield* steer.pending(chat.id)).map((item) => String(item.id))).toEqual([String(receipt.messageID)]) yield* Deferred.succeed(gate, undefined) - expect(Exit.isSuccess(yield* Fiber.await(running))).toBe(true) + const runningExit = yield* Fiber.await(running) + expect(Exit.isSuccess(runningExit), Exit.isFailure(runningExit) ? Cause.pretty(runningExit.cause) : "").toBe(true) expect(yield* llm.calls).toBe(2) const messages = yield* sessions.messages({ sessionID: chat.id }) @@ -805,9 +866,9 @@ on.instance( }, }, }) - expect( - persisted[0]?.parts.filter((part) => part.type === "text" && part.text === "STEERED-ASYNC"), - ).toHaveLength(1) + expect(persisted[0]?.parts.filter((part) => part.type === "text" && part.text === "STEERED-ASYNC")).toHaveLength( + 1, + ) }), 15_000, ) @@ -1106,3 +1167,59 @@ on.instance( }), 20_000, ) + +on.instance( + "promptOrSteer rechecks the durable buffer after joining a runner that cannot absorb the steer", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const steer = yield* SessionSteer.Service + const sessions = yield* Session.Service + const state = yield* SessionRunState.Service + const chat = yield* sessions.create({ + title: "isBusy admission race", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("initial answer") + const initial = yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "initial" }], + }) + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const occupied = yield* state + .ensureRunning( + chat.id, + Effect.succeed(initial), + Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(initial)), + ) + .pipe(Effect.forkChild) + yield* Deferred.await(entered) + expect(yield* state.isBusy(chat.id)).toBe(true) + yield* llm.text("raced steer answer") + + const routed = yield* prompt.promptOrSteer({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "RACED-STEER" }], + }) + expect(routed.kind).toBe("steer") + expect(yield* steer.hasPending(chat.id, "steer")).toBe(true) + + yield* Deferred.succeed(release, undefined) + yield* Fiber.await(occupied) + yield* llm.wait(2) + + expect(yield* steer.hasPending(chat.id, "steer")).toBe(false) + expect( + (yield* sessions.messages({ sessionID: chat.id })).some((message) => + message.parts.some((part) => part.type === "text" && part.text === "RACED-STEER"), + ), + ).toBe(true) + }), + 20_000, +) diff --git a/packages/deepagent-code/test/session/tool-sequence-tracker.test.ts b/packages/deepagent-code/test/session/tool-sequence-tracker.test.ts index c6b124a0..b9a0bb06 100644 --- a/packages/deepagent-code/test/session/tool-sequence-tracker.test.ts +++ b/packages/deepagent-code/test/session/tool-sequence-tracker.test.ts @@ -21,6 +21,8 @@ import { ToolSequenceTracker, withPlanProtocolActivity, } from "@/session/processor" +import { ToolSemanticFingerprint } from "@/tool/semantic-fingerprint" +import type { Tool } from "ai" // --------------------------------------------------------------------------- // Helpers @@ -352,6 +354,29 @@ describe("canonical JSON fingerprint (key order independence)", () => { }) describe("tool-defined semantic fingerprints", () => { + test("read-only tools provide bounded evidence while mutating tools require an explicit result contract", () => { + const tool = {} as Tool + const first = ToolSemanticFingerprint.resolveResult(tool, { output: "first" }, "read") + const retry = ToolSemanticFingerprint.resolveResult(tool, { output: "first" }, "read") + const changed = ToolSemanticFingerprint.resolveResult(tool, { output: "second" }, "read") + + expect(first).toEqual(retry) + expect(first).not.toEqual(changed) + expect(ToolSemanticFingerprint.resolveResult(tool, { output: "first" }, "write")).toBeUndefined() + }) + + test("read-only evidence is canonical for key order and BigInt and fails safe on cycles", () => { + const tool = {} as Tool + const first = ToolSemanticFingerprint.resolveResult(tool, { count: 2n, nested: { b: 2, a: 1 } }, "read") + const reordered = ToolSemanticFingerprint.resolveResult(tool, { nested: { a: 1, b: 2 }, count: 2n }, "read") + const cyclic: { self?: unknown } = {} + cyclic.self = cyclic + + expect(first).toEqual(reordered) + expect(first).toBeDefined() + expect(ToolSemanticFingerprint.resolveResult(tool, cyclic, "read")).toBeUndefined() + }) + test("display-only shell descriptions cannot evade period-1 detection", () => { const t = new ToolSequenceTracker() t.setFingerprintResolver((_tool, input) => { @@ -370,7 +395,7 @@ describe("tool-defined semantic fingerprints", () => { expect(t.detect()?.period).toBe(1) }) - test("equivalent result fingerprints ignore input changes and reset on observable progress", () => { + test("equivalent results from different inputs do not claim no-progress and progress resets exact retries", () => { const t = new ToolSequenceTracker() t.setResultFingerprintResolver((_tool, result) => result) const unchanged = { snapshot: "tree-a", plan: { active: "step-1" } } @@ -378,8 +403,8 @@ describe("tool-defined semantic fingerprints", () => { t.push("1", t.fingerprint("bash", { command: "true" })) expect(t.markDone("1", "bash", { exit: 0, output: "same" }, unchanged)?.count).toBe(1) t.push("2", t.fingerprint("bash", { command: "printf ''" })) - expect(t.markDone("2", "bash", { exit: 0, output: "same" }, unchanged)?.count).toBe(2) - t.push("3", t.fingerprint("bash", { command: "touch artifact" })) + expect(t.markDone("2", "bash", { exit: 0, output: "same" }, unchanged)?.count).toBe(1) + t.push("3", t.fingerprint("bash", { command: "true" })) expect( t.markDone("3", "bash", { exit: 0, output: "same" }, { snapshot: "tree-b", plan: unchanged.plan })?.count, ).toBe(1) @@ -388,7 +413,9 @@ describe("tool-defined semantic fingerprints", () => { t.markDone("4", "bash", { exit: 0, output: "same" }, { snapshot: "tree-b", plan: unchanged.plan })?.count, ).toBe(2) t.push("5", t.fingerprint("bash", { command: "true" })) - expect(t.markDone("5", "bash", { exit: 0, output: "changed" }, unchanged)?.count).toBe(1) + expect( + t.markDone("5", "bash", { exit: 0, output: "changed" }, { snapshot: "tree-b", plan: unchanged.plan })?.count, + ).toBe(1) }) test("tools without a result hook do not claim no-progress evidence", () => { @@ -545,7 +572,12 @@ describe("activity-level plan protocol budget", () => { test("does not collapse a reused provider call ID across assistant turns", () => { const activity = "msg_root_reused_call" const root = { - info: { id: activity, role: "user", time: { created: 1 }, metadata: withPlanProtocolActivity(undefined, activity) }, + info: { + id: activity, + role: "user", + time: { created: 1 }, + metadata: withPlanProtocolActivity(undefined, activity), + }, parts: [], } const failure = (id: string, end: number) => ({ @@ -571,7 +603,12 @@ describe("activity-level plan protocol budget", () => { test("replays protocol outcomes by durable settlement time, not message ID", () => { const activity = "msg_root_ordered" const root = { - info: { id: activity, role: "user", time: { created: 1 }, metadata: withPlanProtocolActivity(undefined, activity) }, + info: { + id: activity, + role: "user", + time: { created: 1 }, + metadata: withPlanProtocolActivity(undefined, activity), + }, parts: [], } const outcome = (id: string, protocol: "invalid" | "success", end: number) => ({ @@ -586,18 +623,32 @@ describe("activity-level plan protocol budget", () => { ], }) - expect(restorePlanProtocolFailures([root, outcome("assistant_z", "invalid", 3), outcome("assistant_a", "success", 2)])).toBe(1) + expect( + restorePlanProtocolFailures([root, outcome("assistant_z", "invalid", 3), outcome("assistant_a", "success", 2)]), + ).toBe(1) }) test("resets when the latest durable user has no activity tag", () => { const activity = "msg_root_tagged" const tagged = { - info: { id: activity, role: "user", time: { created: 1 }, metadata: withPlanProtocolActivity(undefined, activity) }, + info: { + id: activity, + role: "user", + time: { created: 1 }, + metadata: withPlanProtocolActivity(undefined, activity), + }, parts: [], } const failure = { info: { id: "assistant_old", role: "assistant", parentID: activity, time: { created: 2 } }, - parts: [{ id: "old_part", type: "tool", tool: "plan", state: { status: "completed", metadata: { plan_protocol: "invalid" } } }], + parts: [ + { + id: "old_part", + type: "tool", + tool: "plan", + state: { status: "completed", metadata: { plan_protocol: "invalid" } }, + }, + ], } const untagged = { info: { id: "msg_new_untagged", role: "user", time: { created: 3 } }, parts: [] } expect(restorePlanProtocolFailures([tagged, failure, untagged])).toBe(0) diff --git a/packages/deepagent-code/test/tool/plan-write.test.ts b/packages/deepagent-code/test/tool/plan-write.test.ts index 1560b299..02cc4fae 100644 --- a/packages/deepagent-code/test/tool/plan-write.test.ts +++ b/packages/deepagent-code/test/tool/plan-write.test.ts @@ -10,6 +10,7 @@ import { normalizeModelPlanWrite, PlanWriteParameters, renderModelPlanCorrection, + renderModelPlanSuccess, renderPlanRetryBase, } from "../../src/tool/plan-write" @@ -89,12 +90,12 @@ describe("model plan advance normalization", () => { const normalized = normalizeModelPlanWrite(params, null, null) const next = buildPlanFromWriteInput(previous.session_id, normalized, null, null) - expect(normalized.active_step_id).toBeUndefined() + expect("active_step_id" in normalized).toBeFalse() expect(next.steps.every((step) => step.step_id.startsWith("step_"))).toBeTrue() expect(next.active_step_id).toBe(next.steps[1]!.step_id) }) - test("heals the incident-shaped replan that invented an active ID for an unidentified new step", () => { + test("rejects an incident-shaped replan that invents an active ID", () => { const params = decode({ operation: "replan", expected_plan_id: previous.plan_id, @@ -116,12 +117,7 @@ describe("model plan advance normalization", () => { active_step_id: "g5", }) - const normalized = normalizeModelPlanWrite(params, previous, ref) - const next = buildPlanFromWriteInput(previous.session_id, normalized, previous, ref) - - expect(normalized.active_step_id).toBeUndefined() - expect(next.active_step_id).toBe(next.steps[0]!.step_id) - expect(next.active_step_id).not.toBe("g5") + expect(() => normalizeModelPlanWrite(params, previous, ref)).toThrow("unsafe_step_identity") }) test("fills hidden retained-step identity fields from the authoritative replan", () => { @@ -131,7 +127,7 @@ describe("model plan advance normalization", () => { expected_version: ref.version, replan_reason: "refresh statuses without changing retained identities", goal: previous.goal, - steps: previous.steps.map((step) => ({ step_id: step.step_id, title: step.title, status: step.status })), + steps: previous.steps.map((step) => ({ step_id: step.step_id, status: step.status })), }) const next = buildPlanFromWriteInput( @@ -140,8 +136,18 @@ describe("model plan advance normalization", () => { previous, ref, ) - expect(next.steps.map((step) => ({ acceptance: step.acceptance, assigned_agent: step.assigned_agent }))).toEqual( - previous.steps.map((step) => ({ acceptance: step.acceptance, assigned_agent: step.assigned_agent })), + expect( + next.steps.map((step) => ({ + title: step.title, + acceptance: step.acceptance, + assigned_agent: step.assigned_agent, + })), + ).toEqual( + previous.steps.map((step) => ({ + title: step.title, + acceptance: step.acceptance, + assigned_agent: step.assigned_agent, + })), ) }) @@ -158,7 +164,7 @@ describe("model plan advance normalization", () => { expect(() => normalizeModelPlanWrite(params, previous, ref)).toThrow("unsafe_step_identity") }) - test("does not heal an active pointer that matches authority when the active step ID is omitted", () => { + test("rejects a replan active pointer even when it matches current authority", () => { const params = decode({ operation: "replan", expected_plan_id: previous.plan_id, @@ -169,12 +175,10 @@ describe("model plan advance normalization", () => { active_step_id: previous.active_step_id, }) - expect(() => buildPlanFromWriteInput(previous.session_id, normalizeModelPlanWrite(params, previous, ref), previous, ref)).toThrow( - "invalid_active_step", - ) + expect(() => normalizeModelPlanWrite(params, previous, ref)).toThrow("unsafe_step_identity") }) - test("strips model-created IDs on create and keeps replan assumptions when omitted", () => { + test("rejects model-created IDs on create and keeps replan assumptions when omitted", () => { const create = decode({ operation: "create", expected_plan_id: null, @@ -184,9 +188,7 @@ describe("model plan advance normalization", () => { steps: [{ step_id: "model_chosen", title: "create step", status: "active" }], active_step_id: "model_chosen", }) - const normalizedCreate = normalizeModelPlanWrite(create, null, null) - expect(normalizedCreate.steps[0]!.step_id).toBeUndefined() - expect(normalizedCreate.active_step_id).toBeUndefined() + expect(() => normalizeModelPlanWrite(create, null, null)).toThrow("unsafe_step_identity") const replan = decode({ operation: "replan", @@ -204,7 +206,46 @@ describe("model plan advance normalization", () => { expect(normalizeModelPlanWrite(clear, previous, ref).assumptions).toEqual([]) }) - test("keeps rejecting an explicit active ID that disagrees with explicitly identified steps", () => { + test("allocates IDs for new replan steps and derives the active pointer after allocation", () => { + const params = decode({ + operation: "replan", + expected_plan_id: previous.plan_id, + expected_version: ref.version, + replan_reason: "add final validation after implementation", + goal: previous.goal, + steps: [ + { step_id: "s1", status: "done" }, + { step_id: "s2", status: "pending" }, + { title: "Run the provider regression matrix", status: "active" }, + ], + }) + + const next = buildPlanFromWriteInput( + previous.session_id, + normalizeModelPlanWrite(params, previous, ref), + previous, + ref, + ) + expect(next.steps.slice(0, 2).map((step) => step.step_id)).toEqual(["s1", "s2"]) + expect(next.steps[2]!.step_id).toStartWith("step_") + expect(next.active_step_id).toBe(next.steps[2]!.step_id) + expect(next.assumptions).toEqual(previous.assumptions) + }) + + test("rejects a supplied create active pointer even when it is null", () => { + const params = decode({ + operation: "create", + expected_plan_id: null, + expected_version: null, + goal: previous.goal, + steps: [{ title: "create step", status: "pending" }], + active_step_id: null, + }) + + expect(() => normalizeModelPlanWrite(params, null, null)).toThrow("unsafe_step_identity") + }) + + test("rejects an explicit active ID before replan candidate construction", () => { const params = decode({ operation: "replan", expected_plan_id: previous.plan_id, @@ -221,9 +262,29 @@ describe("model plan advance normalization", () => { active_step_id: "g5", }) - expect(() => - buildPlanFromWriteInput(previous.session_id, normalizeModelPlanWrite(params, previous, ref), previous, ref), - ).toThrow("invalid_active_step") + expect(() => normalizeModelPlanWrite(params, previous, ref)).toThrow("unsafe_step_identity") + }) + + test("accepts a replan goal change while retaining omitted hidden identity fields", () => { + const params = decode({ + operation: "replan", + expected_plan_id: previous.plan_id, + expected_version: ref.version, + replan_reason: "the requested outcome changed", + goal: "finish and validate the provider migration", + steps: previous.steps.map((step) => ({ step_id: step.step_id, title: step.title, status: step.status })), + }) + + const next = buildPlanFromWriteInput( + previous.session_id, + normalizeModelPlanWrite(params, previous, ref), + previous, + ref, + ) + expect(next.goal).toBe("finish and validate the provider migration") + expect(next.steps.map((step) => ({ acceptance: step.acceptance, assigned_agent: step.assigned_agent }))).toEqual( + previous.steps.map((step) => ({ acceptance: step.acceptance, assigned_agent: step.assigned_agent })), + ) }) test("rejects multiple active create steps even when active_step_id is omitted", () => { @@ -325,7 +386,7 @@ describe("model plan advance normalization", () => { const normalized = normalizeModelPlanWrite(params, previous, ref) expect(normalized.goal).toBe(previous.goal) - expect(normalized.active_step_id).toBe(previous.active_step_id) + expect("active_step_id" in normalized && normalized.active_step_id).toBe(previous.active_step_id) }) test("rejects duplicate and unknown step IDs before building a candidate", () => { @@ -368,7 +429,7 @@ describe("model plan advance normalization", () => { test("returns schema-valid correction parameters with the exact model-facing field names", () => { const output = renderPlanRetryBase(previous, { id: ref.doc_id, version: ref.version }) const base = JSON.parse(output.slice(output.indexOf("{"))) as Record - const retry = decode({ operation: "advance", ...base }) + const retry = decode(base) expect(output).toContain(`"expected_plan_id":"${previous.plan_id}"`) expect(output).toContain(`"expected_version":${ref.version}`) @@ -392,9 +453,7 @@ describe("model plan advance normalization", () => { const output = renderPlanRetryBase(blocked, { id: ref.doc_id, version: ref.version }) const base = JSON.parse(output.slice(output.indexOf("{"))) as Record - expect(decode({ operation: "advance", ...base }).steps).toEqual([ - { step_id: "s1", status: "blocked", note: "waiting for credentials" }, - ]) + expect(decode(base).steps).toEqual([{ step_id: "s1", status: "blocked", note: "waiting for credentials" }]) }) test("forbids guessing when a correction cannot supply the authoritative version", () => { @@ -405,19 +464,95 @@ describe("model plan advance normalization", () => { }) test("returns operation-specific create and replan corrections without future ID guesses", () => { - const create = renderModelPlanCorrection("create", "invalid_active_step", null, null) - const replan = renderModelPlanCorrection("replan", "invalid_active_step", previous, { + const createParams = decode({ + operation: "create", + expected_plan_id: null, + expected_version: null, + goal: "ship the migration", + steps: [{ step_id: "invented", title: "implement", status: "active" }], + active_step_id: "invented", + }) + const replanParams = decode({ + operation: "replan", + expected_plan_id: previous.plan_id, + expected_version: ref.version, + replan_reason: "change the implementation boundary", + goal: previous.goal, + steps: [{ step_id: "invented", title: "new step", status: "active" }], + }) + const create = renderModelPlanCorrection(createParams, "unsafe_step_identity", null, null) + const replan = renderModelPlanCorrection(replanParams, "unsafe_step_identity", previous, { id: ref.doc_id, version: ref.version, }) + const createRetry = decode(JSON.parse(create.slice(create.indexOf("{")))) + const replanRetry = decode(JSON.parse(replan.slice(replan.indexOf("{")))) - expect(create).toContain('{"expected_plan_id":null,"expected_version":null}') - expect(create).toContain("Omit active_step_id") + expect(createRetry.steps).toEqual([{ title: "implement", status: "active" }]) + expect(createRetry.active_step_id).toBeUndefined() expect(create).toContain("Do not invent a future server ID") - expect(replan).toContain(`"expected_plan_id":"${previous.plan_id}"`) - expect(replan).toContain(`"expected_version":${ref.version}`) - expect(replan).toContain('"step_id":"s1"') - expect(replan).toContain("Omit step_id for every new step") - expect(replan).not.toContain('"active_step_id"') + expect(replanRetry.expected_plan_id).toBe(previous.plan_id) + expect(replanRetry.expected_version).toBe(ref.version) + expect(replanRetry.steps.map((step) => step.step_id)).toEqual(["s1", "s2"]) + expect(replanRetry.steps.map((step) => step.acceptance)).toEqual( + previous.steps.map((step) => step.acceptance ?? undefined), + ) + expect(replan).toContain("for every new step, omit step_id") + expect(replanRetry.active_step_id).toBeUndefined() + }) + + test("omits nullable hidden identity fields from a schema-valid replan correction", () => { + const authority = { + ...previous, + steps: [{ ...previous.steps[0]!, acceptance: null, assigned_agent: null }], + } + const params = decode({ + operation: "replan", + expected_plan_id: authority.plan_id, + expected_version: ref.version, + replan_reason: "correct the plan", + goal: authority.goal, + steps: [{ step_id: "unknown", title: "new", status: "active" }], + }) + const output = renderModelPlanCorrection(params, "unsafe_step_identity", authority, { + id: ref.doc_id, + version: ref.version, + }) + const retry = decode(JSON.parse(output.slice(output.indexOf("{")))) + + expect(retry.steps).toEqual([ + { + step_id: authority.steps[0]!.step_id, + title: authority.steps[0]!.title, + status: authority.steps[0]!.status, + }, + ]) + }) + + test("returns allocated IDs in a schema-valid success payload", () => { + const params = decode({ + operation: "create", + expected_plan_id: null, + expected_version: null, + goal: "ship the provider migration", + steps: [ + { title: "Inspect the provider boundary", status: "done" }, + { title: "Implement the server-side merge", status: "active" }, + ], + }) + const created = buildPlanFromWriteInput( + previous.session_id, + normalizeModelPlanWrite(params, null, null), + null, + null, + ) + const output = renderModelPlanSuccess(created, 1) + const retry = decode(JSON.parse(output.slice(output.lastIndexOf("\n{") + 1))) + + expect(retry.operation).toBe("advance") + expect(retry.expected_plan_id).toBe(created.plan_id) + expect(retry.expected_version).toBe(1) + expect(retry.active_step_id).toBe(created.active_step_id) + expect(retry.steps.map((step) => step.step_id)).toEqual(created.steps.map((step) => step.step_id)) }) }) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 93a5ea24..ee70487a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -26,6 +26,8 @@ "test:llm-ext:sidecar": "bun run build && node --experimental-strip-types ./scripts/live-llm/packaged-sidecar.ts", "test:llm-release:subagents": "bun run build && node --experimental-strip-types ./scripts/live-llm/desktop-subagents.ts", "test:llm-release:ui": "bun run build && node --experimental-strip-types ./scripts/live-llm/desktop-ui.ts", + "test:llm-live:activity-progress-restart": "bun run build && node --experimental-strip-types ./scripts/live-llm/activity-progress-restart.ts", + "test:llm-release:activity-progress-package": "bun run build && DEEPAGENT_CODE_ALLOW_UNSIGNED=1 bun run package:mac && node --experimental-strip-types ./scripts/live-llm/activity-progress-package.ts", "test:llm-release:long-session": "bun run build && node --experimental-strip-types ./scripts/live-llm/long-session.ts", "test:llm-release:observed": "bun run build && node --experimental-strip-types ./scripts/live-llm/desktop-observed.ts", "package": "bun ./scripts/package.ts", diff --git a/packages/desktop/scripts/live-llm/activity-progress-package.ts b/packages/desktop/scripts/live-llm/activity-progress-package.ts new file mode 100644 index 00000000..5bed179c --- /dev/null +++ b/packages/desktop/scripts/live-llm/activity-progress-package.ts @@ -0,0 +1,386 @@ +import { strict as assert } from "node:assert" +import { createHash, randomUUID } from "node:crypto" +import { execFile } from "node:child_process" +import { createReadStream } from "node:fs" +import { readdir, rm, stat } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { DatabaseSync } from "node:sqlite" +import { + close, + closeAll, + createSession, + focusSession, + launch, + loadLiveConfig, + waitFor, + writeArtifact, + type Runtime, +} from "./runtime.ts" + +const suite = "activity-progress-package" +const config = await loadLiveConfig() +if (config.modelID !== "deepseek-v4-flash") { + throw new Error("Packaged activity progress test requires the DeepSeek deepseek-v4-flash configuration") +} +if (process.platform !== "darwin") throw new Error("Packaged activity progress test currently requires macOS") + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const executablePath = await findPackagedExecutable(path.join(packageRoot, "dist")) +const appAsarPath = path.resolve(path.dirname(executablePath), "../Resources/app.asar") +const executableHash = await hashFile(executablePath) +const appAsarHash = await hashFile(appAsarPath) +const sourceCommit = ( + await promisify(execFile)("git", ["rev-parse", "HEAD"], { cwd: path.resolve(packageRoot, "../..") }) +).stdout.trim() +const marker = randomUUID().replaceAll("-", "") +const activityID = `activity-package-${marker}` +const triggerText = `package-trigger-${marker}` +const steerText = `package-steer-${marker}` +const oldProgress = `package-progress-old-${marker}` +const latestProgress = `package-progress-latest-${marker}` +const finalText = `package-final-${marker}` +const startedAt = Date.now() +let setup: Runtime | undefined +let progressRuntime: Runtime | undefined +let finalRuntime: Runtime | undefined +let root: string | undefined + +try { + setup = await launch(suite, config, { executablePath, cleanupRoot: false }) + root = setup.root + const packaged = await setup.app.evaluate(({ app }) => ({ packaged: app.isPackaged, version: app.getVersion() })) + assert.equal(packaged.packaged, true) + const session = await createSession(setup, "Packaged activity progress projection", "live-ui") + await focusSession(setup, session) + await close(setup) + setup = undefined + + seedProgress(path.join(root, "deepagent.sqlite"), { + sessionID: session.id, + workspace: path.join(root, "workspace"), + modelID: config.modelID, + activityID, + triggerText, + steerText, + oldProgress, + latestProgress, + }) + + progressRuntime = await launch(suite, config, { root, executablePath, cleanupRoot: false }) + const progressErrors: string[] = [] + progressRuntime.page.on("pageerror", (error) => progressErrors.push(error.stack ?? error.message)) + await progressRuntime.page + .getByRole("heading", { name: "Packaged activity progress projection" }) + .waitFor({ state: "visible", timeout: 60_000 }) + const progressCounts = await waitFor( + async () => { + const counts = await textCounts(progressRuntime!, { + triggerText, + steerText, + oldProgress, + latestProgress, + finalText, + }) + if (counts.trigger === 1 && counts.steer === 1 && counts.oldProgress === 0 && counts.latestProgress === 1) { + return counts + } + }, + "packaged latest activity progress projection", + 60_000, + ) + assert.deepEqual(progressErrors, []) + const artifactDirectory = path.join(packageRoot, ".artifacts/live-llm") + const progressScreenshot = path.join(artifactDirectory, `${suite}-progress.png`) + await progressRuntime.page.screenshot({ path: progressScreenshot, fullPage: false }) + await close(progressRuntime) + progressRuntime = undefined + + appendFinal(path.join(root, "deepagent.sqlite"), { + sessionID: session.id, + workspace: path.join(root, "workspace"), + modelID: config.modelID, + activityID, + parentID: ids(marker).steer, + finalText, + }) + + finalRuntime = await launch(suite, config, { root, executablePath, cleanupRoot: false }) + const finalErrors: string[] = [] + finalRuntime.page.on("pageerror", (error) => finalErrors.push(error.stack ?? error.message)) + await finalRuntime.page + .getByRole("heading", { name: "Packaged activity progress projection" }) + .waitFor({ state: "visible", timeout: 60_000 }) + const finalCounts = await waitFor( + async () => { + const counts = await textCounts(finalRuntime!, { triggerText, steerText, oldProgress, latestProgress, finalText }) + if ( + counts.trigger === 1 && + counts.steer === 1 && + counts.oldProgress === 0 && + counts.latestProgress === 0 && + counts.final === 1 + ) { + return counts + } + }, + "packaged final activity projection", + 60_000, + ) + const editor = finalRuntime.page.locator('[data-component="prompt-input"]') + await editor.waitFor({ state: "visible", timeout: 30_000 }) + const submitLabel = await finalRuntime.page.locator('[data-action="prompt-submit"]').getAttribute("aria-label") + assert.equal(Boolean(submitLabel && !/stop|停止/i.test(submitLabel)), true) + assert.deepEqual(finalErrors, []) + const finalScreenshot = path.join(packageRoot, ".artifacts/live-llm", `${suite}-final.png`) + await finalRuntime.page.screenshot({ path: finalScreenshot, fullPage: false }) + + await writeArtifact(suite, { + suite, + mode: "release", + stack: "packaged-renderer-ui", + status: "passed", + fingerprint: { + providerID: "deepseek", + runtimeProviderID: "live-deepseek", + modelID: config.modelID, + modelRevision: config.modelRevision, + baseURL: config.baseURL, + }, + package: { + sourceCommit, + version: packaged.version, + executable: path.relative(packageRoot, executablePath), + executableHash, + appAsarHash, + isPackaged: packaged.packaged, + }, + evidence: { + sessionID: session.id, + activityIDHash: createHash("sha256").update(activityID).digest("hex"), + progressCounts, + finalCounts, + progressScreenshot: path.basename(progressScreenshot), + finalScreenshot: path.basename(finalScreenshot), + pageErrors: [...progressErrors, ...finalErrors], + terminalComposerRendered: true, + }, + durationMs: Date.now() - startedAt, + completedAt: new Date().toISOString(), + }) + console.log(`${suite}: passed (${path.basename(executablePath)}, progress -> final)`) +} finally { + if (setup) await close(setup).catch(() => undefined) + if (progressRuntime) await close(progressRuntime).catch(() => undefined) + if (finalRuntime) await close(finalRuntime).catch(() => undefined) + await closeAll() + if (root && process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") await rm(root, { recursive: true, force: true }) +} + +function seedProgress( + databasePath: string, + input: { + sessionID: string + workspace: string + modelID: string + activityID: string + triggerText: string + steerText: string + oldProgress: string + latestProgress: string + }, +) { + const database = new DatabaseSync(databasePath) + const value = ids(marker) + const now = Date.now() + try { + database.exec("PRAGMA foreign_keys = ON; BEGIN IMMEDIATE") + insertMessage(database, value.trigger, input.sessionID, now, user(input.triggerText, now, input.modelID)) + insertPart(database, value.triggerPart, value.trigger, input.sessionID, now, { + type: "text", + text: input.triggerText, + }) + insertMessage( + database, + value.oldAssistant, + input.sessionID, + now + 1, + assistant(value.trigger, now + 1, input.workspace, input.modelID, "tool-calls"), + ) + insertPart(database, value.oldProgressPart, value.oldAssistant, input.sessionID, now + 1, { + type: "text", + text: input.oldProgress, + metadata: { deepagent_activity_progress: { activity_id: input.activityID, revision: 0, state: "progress" } }, + }) + insertMessage(database, value.steer, input.sessionID, now + 2, user(input.steerText, now + 2, input.modelID)) + insertPart(database, value.steerPart, value.steer, input.sessionID, now + 2, { + type: "text", + text: input.steerText, + }) + insertMessage( + database, + value.latestAssistant, + input.sessionID, + now + 3, + assistant(value.steer, now + 3, input.workspace, input.modelID, "tool-calls"), + ) + insertPart(database, value.latestProgressPart, value.latestAssistant, input.sessionID, now + 3, { + type: "text", + text: input.latestProgress, + metadata: { deepagent_activity_progress: { activity_id: input.activityID, revision: 1, state: "progress" } }, + }) + database.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(now + 3, input.sessionID) + database.exec("COMMIT") + } catch (error) { + database.exec("ROLLBACK") + throw error + } finally { + database.close() + } +} + +function appendFinal( + databasePath: string, + input: { + sessionID: string + workspace: string + modelID: string + activityID: string + parentID: string + finalText: string + }, +) { + const database = new DatabaseSync(databasePath) + const value = ids(marker) + const now = Date.now() + try { + database.exec("PRAGMA foreign_keys = ON; BEGIN IMMEDIATE") + insertMessage( + database, + value.finalAssistant, + input.sessionID, + now, + assistant(input.parentID, now, input.workspace, input.modelID, "stop"), + ) + insertPart(database, value.finalPart, value.finalAssistant, input.sessionID, now, { + type: "text", + text: input.finalText, + metadata: { deepagent_activity_progress: { activity_id: input.activityID, revision: 2, state: "final" } }, + }) + database.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(now, input.sessionID) + database.exec("COMMIT") + } catch (error) { + database.exec("ROLLBACK") + throw error + } finally { + database.close() + } +} + +function insertMessage(database: DatabaseSync, id: string, sessionID: string, time: number, data: object) { + database + .prepare("INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)") + .run(id, sessionID, time, time, JSON.stringify(data)) +} + +function insertPart( + database: DatabaseSync, + id: string, + messageID: string, + sessionID: string, + time: number, + data: object, +) { + database + .prepare( + "INSERT INTO part (id, message_id, session_id, provenance, time_created, time_updated, data) VALUES (?, ?, ?, NULL, ?, ?, ?)", + ) + .run(id, messageID, sessionID, time, time, JSON.stringify(data)) +} + +function user(text: string, created: number, modelID: string) { + return { + role: "user", + time: { created }, + agent: "live-ui", + model: { providerID: "live-deepseek", modelID }, + metadata: { packageProjectionFixture: true, textHash: createHash("sha256").update(text).digest("hex") }, + } +} + +function assistant(parentID: string, created: number, workspace: string, modelID: string, finish: string) { + return { + role: "assistant", + time: { created, completed: created + 1 }, + parentID, + modelID, + providerID: "live-deepseek", + mode: "general", + agent: "live-ui", + path: { cwd: workspace, root: workspace }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish, + } +} + +function ids(value: string) { + return { + trigger: `msg_pkg_trigger_${value}`, + triggerPart: `prt_pkg_trigger_${value}`, + oldAssistant: `msg_pkg_old_${value}`, + oldProgressPart: `prt_pkg_old_${value}`, + steer: `msg_pkg_steer_${value}`, + steerPart: `prt_pkg_steer_${value}`, + latestAssistant: `msg_pkg_latest_${value}`, + latestProgressPart: `prt_pkg_latest_${value}`, + finalAssistant: `msg_pkg_final_${value}`, + finalPart: `prt_pkg_final_${value}`, + } +} + +async function textCounts( + runtime: Runtime, + input: { triggerText: string; steerText: string; oldProgress: string; latestProgress: string; finalText: string }, +) { + return { + trigger: await runtime.page.getByText(input.triggerText, { exact: false }).count(), + steer: await runtime.page.getByText(input.steerText, { exact: false }).count(), + oldProgress: await runtime.page.getByText(input.oldProgress, { exact: false }).count(), + latestProgress: await runtime.page.getByText(input.latestProgress, { exact: false }).count(), + final: await runtime.page.getByText(input.finalText, { exact: false }).count(), + } +} + +async function findPackagedExecutable(directory: string) { + const candidates: Array<{ path: string; modified: number }> = [] + async function visit(current: string) { + for (const entry of await readdir(current, { withFileTypes: true })) { + const target = path.join(current, entry.name) + if (entry.isDirectory()) { + await visit(target) + continue + } + const segments = path.relative(directory, target).split(path.sep) + const app = segments.findIndex((segment) => segment.endsWith(".app")) + if (app === -1 || segments[app + 1] !== "Contents" || segments[app + 2] !== "MacOS") continue + const info = await stat(target) + if ((info.mode & 0o111) !== 0) candidates.push({ path: target, modified: info.mtimeMs }) + } + } + await visit(directory) + const executable = candidates.sort((left, right) => right.modified - left.modified)[0]?.path + if (!executable) throw new Error(`No packaged macOS executable found under ${directory}`) + return executable +} + +function hashFile(file: string) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256") + createReadStream(file) + .on("error", reject) + .on("data", (chunk) => hash.update(chunk)) + .on("end", () => resolve(hash.digest("hex"))) + }) +} diff --git a/packages/desktop/scripts/live-llm/activity-progress-restart.ts b/packages/desktop/scripts/live-llm/activity-progress-restart.ts new file mode 100644 index 00000000..e656e5ec --- /dev/null +++ b/packages/desktop/scripts/live-llm/activity-progress-restart.ts @@ -0,0 +1,243 @@ +import { strict as assert } from "node:assert" +import { randomUUID } from "node:crypto" +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { DatabaseSync } from "node:sqlite" +import { + close, + closeAll, + createSession, + hardKill, + launch, + loadLiveConfig, + messages, + preflight, + startPrompt, + waitFor, + writeArtifact, + type Runtime, +} from "./runtime.ts" + +const suite = "activity-progress-restart" +const config = await loadLiveConfig() +if (config.modelID !== "deepseek-v4-flash") { + throw new Error("Activity progress restart test requires DeepSeek deepseek-v4-flash") +} +const preflightResult = await preflight(config) +const startedAt = Date.now() +const results: RestartResult[] = [] + +const cases = [ + { + name: "streaming-recovery-required", + point: "after_provider_streaming", + prompt: (marker: string) => `Reply with exactly ${marker}. Do not call any tool.`, + expectedBefore: { activity: "active", progress: "provisional", receipt: "streaming" }, + expectedAfter: { activity: "recovery_required", progress: "recovery_required" }, + }, + { + name: "terminal-receipt-final-reconciliation", + point: "after_provider_receipt_terminal", + prompt: (marker: string) => `Reply with exactly ${marker}. Do not call any tool.`, + expectedBefore: { activity: "active", progress: "provisional", receipt: "settled" }, + expectedAfter: { activity: "settled", progress: "final" }, + }, + { + name: "settled-tool-progress-recovery-required", + point: "after_progress_settled", + prompt: (_marker: string) => + "Read restart-fact.txt exactly once. After the tool result, return the exact file content and do not call any other tool.", + file: "restart-fact.txt", + expectedBefore: { activity: "active", progress: "progress", receipt: "settled" }, + expectedAfter: { activity: "recovery_required", progress: "progress" }, + }, +] as const + +try { + for (const testCase of cases) { + console.log(`${suite}: starting ${testCase.name}`) + const root = await mkdtemp(path.join(os.tmpdir(), `deepagent-code-${suite}-${testCase.name}-`)) + const markerFile = path.join(root, "crash-marker.json") + const marker = `restart-${randomUUID()}` + let initial: Runtime | undefined + let restarted: Runtime | undefined + try { + initial = await launch(suite, config, { + root, + cleanupRoot: false, + environment: { + DEEPAGENT_CODE_TEST_ACTIVITY_CRASH_POINT: testCase.point, + DEEPAGENT_CODE_TEST_ACTIVITY_CRASH_MARKER: markerFile, + }, + }) + if ("file" in testCase) await writeFile(path.join(initial.workspace, testCase.file), `${marker}\n`) + const session = await createSession(initial, `DeepSeek restart ${testCase.name}`, "live-ui") + const promptRequest = startPrompt(initial, session.id, testCase.prompt(marker), "live-ui").then( + () => ({ state: "acknowledged" as const }), + (error) => ({ state: "disconnected" as const, error: error instanceof Error ? error.message : String(error) }), + ) + const crash = await waitFor( + async () => { + const value = await readFile(markerFile, "utf8").catch(() => undefined) + if (value) return JSON.parse(value) as { point: string; pid: number; reachedAt: number } + }, + `${testCase.name} crash point`, + config.timeoutMs, + ) + console.log(`${suite}: reached ${testCase.point} in sidecar ${crash.pid}`) + assert.equal(crash.point, testCase.point) + const before = inspect(path.join(root, "deepagent.sqlite"), session.id) + console.log(`${suite}: captured durable pre-kill state for ${session.id}`) + assert.equal(before.activity?.state, testCase.expectedBefore.activity) + assert.equal(before.progress.at(-1)?.state, testCase.expectedBefore.progress) + assert.equal(before.receipts.at(-1)?.provider_state, testCase.expectedBefore.receipt) + const initialAppPID = initial.app.process().pid + console.log(`${suite}: sending SIGKILL to Electron ${initialAppPID}`) + await hardKill(initial) + initial = undefined + const promptOutcome = await Promise.race([ + promptRequest, + new Promise<{ state: "timed_out" }>((resolve) => setTimeout(() => resolve({ state: "timed_out" }), 10_000)), + ]) + assert.notEqual(promptOutcome.state, "timed_out") + + console.log(`${suite}: restarting from ${root}`) + restarted = await launch(suite, config, { root, cleanupRoot: false }) + const restartedAppPID = restarted.app.process().pid + assert.notEqual(restartedAppPID, initialAppPID) + const after = await waitFor( + async () => { + const value = inspect(path.join(root, "deepagent.sqlite"), session.id) + if ( + value.activity?.state === testCase.expectedAfter.activity && + value.progress.at(-1)?.state === testCase.expectedAfter.progress + ) + return value + }, + `${testCase.name} restart reconciliation`, + 30_000, + ) + console.log(`${suite}: reconciled ${testCase.name} in Electron ${restartedAppPID}`) + assert.equal(after.receipts.length, before.receipts.length) + assert.equal(after.messageCount, before.messageCount) + assert.equal(after.toolPartCount, before.toolPartCount) + assert.equal(after.userMessageCount, 1) + if (testCase.point === "after_provider_receipt_terminal") { + assert.equal( + (await messages(restarted, session.id)).some((message) => + message.parts.some((part) => part.type === "text" && part.text.includes(marker)), + ), + true, + ) + } + results.push({ + name: testCase.name, + point: testCase.point, + sessionID: session.id, + crash, + initialAppPID, + restartedAppPID, + promptOutcome, + before, + after, + }) + } finally { + if (initial) await close(initial).catch(() => undefined) + if (restarted) await close(restarted).catch(() => undefined) + if (process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") await rm(root, { recursive: true, force: true }) + } + } + + await writeArtifact(suite, { + suite, + mode: "live", + stack: "desktop-sidecar-process-restart", + status: "passed", + fingerprint: { + providerID: "deepseek", + runtimeProviderID: "live-deepseek", + modelID: config.modelID, + modelRevision: config.modelRevision, + baseURL: config.baseURL, + }, + preflight: preflightResult, + evidence: results, + durationMs: Date.now() - startedAt, + completedAt: new Date().toISOString(), + }) + console.log(`${suite}: passed (deepseek/${config.modelID}, ${results.length} SIGKILL boundaries)`) +} finally { + await closeAll() +} + +type Snapshot = ReturnType +type RestartResult = { + name: string + point: string + sessionID: string + crash: { point: string; pid: number; reachedAt: number } + initialAppPID?: number + restartedAppPID?: number + promptOutcome: { state: "acknowledged" } | { state: "disconnected"; error: string } + before: Snapshot + after: Snapshot +} + +function inspect(databasePath: string, sessionID: string) { + const database = new DatabaseSync(databasePath, { readOnly: true }) + try { + database.exec("PRAGMA busy_timeout = 5000") + const activity = database + .prepare( + "SELECT activity_id, owner_token, state, terminal_reason FROM session_legacy_activity WHERE session_id = ? ORDER BY ordinal DESC LIMIT 1", + ) + .get(sessionID) as + | { activity_id: string; owner_token: string; state: string; terminal_reason: string | null } + | undefined + const progress = database + .prepare( + "SELECT revision, assistant_message_id, provider_receipt_id, state, finish_observed FROM session_activity_progress WHERE activity_id = ? ORDER BY revision", + ) + .all(activity?.activity_id ?? "") as Array<{ + revision: number + assistant_message_id: string + provider_receipt_id: string + state: string + finish_observed: string | null + }> + const receipts = database + .prepare( + "SELECT receipt_id, assistant_message_id, provider_state, request_state, call_ids FROM session_tool_request_receipt WHERE session_id = ? ORDER BY request_ordinal", + ) + .all(sessionID) as Array<{ + receipt_id: string + assistant_message_id: string + provider_state: string + request_state: string + call_ids: string + }> + const counts = database + .prepare( + `SELECT + (SELECT COUNT(*) FROM message WHERE session_id = ?) AS message_count, + (SELECT COUNT(*) FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'user') AS user_message_count, + (SELECT COUNT(*) FROM part WHERE session_id = ? AND json_extract(data, '$.type') = 'tool') AS tool_part_count`, + ) + .get(sessionID, sessionID, sessionID) as { + message_count: number + user_message_count: number + tool_part_count: number + } + return { + activity, + progress, + receipts: receipts.map((receipt) => ({ ...receipt, call_ids: JSON.parse(receipt.call_ids) as string[] })), + messageCount: counts.message_count, + userMessageCount: counts.user_message_count, + toolPartCount: counts.tool_part_count, + } + } finally { + database.close() + } +} diff --git a/packages/desktop/scripts/live-llm/runtime.ts b/packages/desktop/scripts/live-llm/runtime.ts index 671a7f35..873b06bd 100644 --- a/packages/desktop/scripts/live-llm/runtime.ts +++ b/packages/desktop/scripts/live-llm/runtime.ts @@ -72,6 +72,14 @@ export type Runtime = { permissionErrors: string[] permissionAbort: AbortController permissionTask: Promise + cleanupRoot: boolean +} + +export type LaunchOptions = { + root?: string + executablePath?: string + environment?: Readonly> + cleanupRoot?: boolean } const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") @@ -129,8 +137,10 @@ export async function preflight(config: LiveConfig) { return { durationMs: Date.now() - startedAt } } -export async function launch(name: string, config: LiveConfig) { - const root = await realpath(await mkdtemp(path.join(os.tmpdir(), `deepagent-code-${name}-`))) +export async function launch(name: string, config: LiveConfig, options: LaunchOptions = {}) { + const root = options.root + ? await realpath(options.root) + : await realpath(await mkdtemp(path.join(os.tmpdir(), `deepagent-code-${name}-`))) const workspace = path.join(root, "workspace") await mkdir(workspace, { recursive: true }) const env = Object.fromEntries( @@ -153,108 +163,112 @@ export async function launch(name: string, config: LiveConfig) { "PATHEXT", ].flatMap((key) => (process.env[key] ? [[key, process.env[key]]] : [])), ) - Object.assign(env, { - HOME: path.join(root, "home"), - XDG_DATA_HOME: path.join(root, "data"), - XDG_CONFIG_HOME: path.join(root, "config"), - XDG_CACHE_HOME: path.join(root, "cache"), - XDG_STATE_HOME: path.join(root, "state"), - DEEPAGENT_CODE_TEST_ONBOARDING: "1", - DEEPAGENT_CODE_TEST_ROOT: root, - DEEPAGENT_CODE_TEST_HOME: path.join(root, "home"), - DEEPAGENT_CODE_DB: path.join(root, "deepagent.sqlite"), - DEEPAGENT_CODE_DISABLE_AUTOUPDATE: "1", - DEEPAGENT_CODE_DISABLE_CHANNEL_DB: "1", - DEEPAGENT_CODE_DISABLE_DEFAULT_PLUGINS: "1", - DEEPAGENT_CODE_DISABLE_EXTERNAL_SKILLS: "1", - DEEPAGENT_CODE_DISABLE_LSP_DOWNLOAD: "1", - DEEPAGENT_CODE_DISABLE_MODELS_FETCH: "1", - DEEPAGENT_CODE_DISABLE_SHELL_ENV: "1", - DEEPAGENT_CODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "true", - DEEPAGENT_CODE_LIVE_LLM_API_KEY_FILE: config.apiKeyFile, - DEEPAGENT_ENABLED: "false", - DEEPAGENT_CODE_CONFIG_CONTENT: JSON.stringify({ - snapshot: false, - enabled_providers: ["live-deepseek"], - model: `live-deepseek/${config.modelID}`, - permission: { "*": "deny", read: "allow", question: "allow" }, - agent: { - auto: { - mode: "primary", - prompt: - "When a visible isolated desktop contract test is requested, follow its tool sequence exactly and do not simulate tool output.", - permission: { - "*": "deny", - task: "allow", - task_status: "allow", - task_read: "allow", - read: "allow", - edit: "allow", - question: "allow", + Object.assign( + env, + { + HOME: path.join(root, "home"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + DEEPAGENT_CODE_TEST_ONBOARDING: "1", + DEEPAGENT_CODE_TEST_ROOT: root, + DEEPAGENT_CODE_TEST_HOME: path.join(root, "home"), + DEEPAGENT_CODE_DB: path.join(root, "deepagent.sqlite"), + DEEPAGENT_CODE_DISABLE_AUTOUPDATE: "1", + DEEPAGENT_CODE_DISABLE_CHANNEL_DB: "1", + DEEPAGENT_CODE_DISABLE_DEFAULT_PLUGINS: "1", + DEEPAGENT_CODE_DISABLE_EXTERNAL_SKILLS: "1", + DEEPAGENT_CODE_DISABLE_LSP_DOWNLOAD: "1", + DEEPAGENT_CODE_DISABLE_MODELS_FETCH: "1", + DEEPAGENT_CODE_DISABLE_SHELL_ENV: "1", + DEEPAGENT_CODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "true", + DEEPAGENT_CODE_LIVE_LLM_API_KEY_FILE: config.apiKeyFile, + DEEPAGENT_ENABLED: "false", + DEEPAGENT_CODE_CONFIG_CONTENT: JSON.stringify({ + snapshot: false, + enabled_providers: ["live-deepseek"], + model: `live-deepseek/${config.modelID}`, + permission: { "*": "deny", read: "allow", question: "allow" }, + agent: { + auto: { + mode: "primary", + prompt: + "When a visible isolated desktop contract test is requested, follow its tool sequence exactly and do not simulate tool output.", + permission: { + "*": "deny", + task: "allow", + task_status: "allow", + task_read: "allow", + read: "allow", + edit: "allow", + question: "allow", + }, }, - }, - "live-parent": { - mode: "primary", - prompt: - "This is a constrained desktop contract test. Follow the requested tool sequence exactly and do not simulate tool output.", - permission: { "*": "deny", task: "allow", task_status: "allow", task_read: "allow", read: "deny" }, - }, - "live-ui": { - mode: "primary", - prompt: - "This is a constrained desktop UI contract test. Follow the requested tool sequence exactly and do not simulate tool output.", - permission: { "*": "deny", read: "allow" }, - }, - "live-long": { - mode: "primary", - prompt: - "This is an isolated long-session test. Use durable conversation evidence, follow explicit tool constraints, and never fabricate a tool result.", - permission: { "*": "deny", read: "allow", question: "allow" }, - }, - "live-observed": { - mode: "primary", - prompt: - "This is a visible isolated desktop contract test. Follow the requested tool sequence exactly and do not simulate tool output.", - permission: { - "*": "deny", - task: "allow", - task_status: "allow", - task_read: "allow", - read: "allow", - edit: "allow", - question: "allow", + "live-parent": { + mode: "primary", + prompt: + "This is a constrained desktop contract test. Follow the requested tool sequence exactly and do not simulate tool output.", + permission: { "*": "deny", task: "allow", task_status: "allow", task_read: "allow", read: "deny" }, }, - }, - }, - provider: { - "live-deepseek": { - name: "DeepSeek V4 Flash isolated desktop test", - env: [], - npm: "@ai-sdk/openai-compatible", - api: config.baseURL, - options: { - apiKey: `{file:${config.apiKeyFile}}`, - baseURL: config.baseURL, - maxRetries: 0, - timeout: config.timeoutMs, + "live-ui": { + mode: "primary", + prompt: + "This is a constrained desktop UI contract test. Follow the requested tool sequence exactly and do not simulate tool output.", + permission: { "*": "deny", read: "allow" }, + }, + "live-long": { + mode: "primary", + prompt: + "This is an isolated long-session test. Use durable conversation evidence, follow explicit tool constraints, and never fabricate a tool result.", + permission: { "*": "deny", read: "allow", question: "allow" }, }, - models: { - [config.modelID]: { - id: config.modelID, - name: "DeepSeek V4 Flash isolated desktop test", - reasoning: false, - temperature: true, - tool_call: true, - limit: { context: 1_000_000, output: 2048 }, - cost: { input: 0, output: 0 }, - modalities: { input: ["text"], output: ["text"] }, - options: { thinking: { type: "disabled" }, maxTokens: 1024, temperature: 0 }, + "live-observed": { + mode: "primary", + prompt: + "This is a visible isolated desktop contract test. Follow the requested tool sequence exactly and do not simulate tool output.", + permission: { + "*": "deny", + task: "allow", + task_status: "allow", + task_read: "allow", + read: "allow", + edit: "allow", + question: "allow", }, }, }, - }, - }), - }) + provider: { + "live-deepseek": { + name: "DeepSeek V4 Flash isolated desktop test", + env: [], + npm: "@ai-sdk/openai-compatible", + api: config.baseURL, + options: { + apiKey: `{file:${config.apiKeyFile}}`, + baseURL: config.baseURL, + maxRetries: 0, + timeout: config.timeoutMs, + }, + models: { + [config.modelID]: { + id: config.modelID, + name: "DeepSeek V4 Flash isolated desktop test", + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 1_000_000, output: 2048 }, + cost: { input: 0, output: 0 }, + modalities: { input: ["text"], output: ["text"] }, + options: { thinking: { type: "disabled" }, maxTokens: 1024, temperature: 0 }, + }, + }, + }, + }, + }), + }, + options.environment, + ) await Promise.all([ mkdir(env.HOME, { recursive: true }), mkdir(env.XDG_DATA_HOME, { recursive: true }), @@ -263,7 +277,12 @@ export async function launch(name: string, config: LiveConfig) { mkdir(env.XDG_STATE_HOME, { recursive: true }), mkdir(env.DEEPAGENT_CODE_TEST_HOME, { recursive: true }), ]) - const app = await electron.launch({ args: [main], env, timeout: 90_000 }) + const app = await electron.launch({ + args: options.executablePath ? [] : [main], + ...(options.executablePath ? { executablePath: options.executablePath } : {}), + env, + timeout: 90_000, + }) activeApps.add(app) const page = await app.firstWindow({ timeout: 90_000 }) await page.waitForFunction(() => Boolean((window as unknown as { api?: unknown }).api)) @@ -283,6 +302,7 @@ export async function launch(name: string, config: LiveConfig) { permissionErrors: [], permissionAbort, permissionTask: Promise.resolve(), + cleanupRoot: options.cleanupRoot ?? true, } satisfies Runtime runtime.permissionTask = monitorPermissions(runtime) activePermissionMonitors.set(app, { abort: permissionAbort, task: runtime.permissionTask }) @@ -302,11 +322,71 @@ export async function close(runtime: Runtime) { await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 2_000))]) } activeApps.delete(runtime.app) - if (process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") { + if (runtime.cleanupRoot && process.env.DEEPAGENT_CODE_KEEP_LIVE_SMOKE !== "1") { await rm(runtime.root, { recursive: true, force: true }) } } +export async function hardKill(runtime: Runtime) { + runtime.permissionAbort.abort() + activePermissionMonitors.delete(runtime.app) + const child = runtime.app.process() + const exited = new Promise((resolve) => child.once("exit", () => resolve())) + child.kill("SIGKILL") + await Promise.race([ + exited, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Desktop did not exit after SIGKILL")), 10_000), + ), + ]) + await runtime.permissionTask + activeApps.delete(runtime.app) + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + const alive = await fetch(new URL("/global/health", runtime.server.url), { + headers: { + authorization: `Basic ${Buffer.from(`${runtime.server.username}:${runtime.server.password}`).toString("base64")}`, + }, + signal: AbortSignal.timeout(1_000), + }) + .then((response) => response.ok) + .catch(() => false) + if (!alive) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error("Desktop sidecar remained reachable after SIGKILL") +} + +export async function focusSession(runtime: Runtime, session: Session) { + const slug = Buffer.from(runtime.workspace).toString("base64url") + const sessionKey = `local\u0000${slug}/${session.id}` + await runtime.page.evaluate( + async ({ layout, pageLayout, server }) => { + const api = ( + window as unknown as { + api: { storeSet(name: string, key: string, value: string): Promise } + } + ).api + await api.storeSet("deepagent.global.dat", "layout", JSON.stringify(layout)) + await api.storeSet("deepagent.global.dat", "layout.page", JSON.stringify(pageLayout)) + await api.storeSet("deepagent.global.dat", "server", JSON.stringify(server)) + }, + { + layout: { sessionView: { [sessionKey]: { scroll: {} } } }, + pageLayout: { + lastProjectSession: { + [runtime.workspace]: { directory: runtime.workspace, id: session.id, at: Date.now() }, + }, + }, + server: { + list: [], + projects: { local: [{ worktree: runtime.workspace, expanded: true }] }, + lastProject: { local: runtime.workspace }, + }, + }, + ) +} + export async function closeAll() { for (const monitor of activePermissionMonitors.values()) monitor.abort.abort() await Promise.all([...activePermissionMonitors.values()].map((monitor) => monitor.task)) diff --git a/script/run-live-llm-all.ts b/script/run-live-llm-all.ts index 8958463b..d6b3b7c4 100644 --- a/script/run-live-llm-all.ts +++ b/script/run-live-llm-all.ts @@ -230,6 +230,12 @@ export const suites: Suite[] = [ command: ["bun", "run", "test:llm-live:plan-advance"], realLLM: true, }, + { + id: "live:plan-create-replan", + package: "deepagent-code", + command: ["bun", "run", "test:llm-live:plan-create-replan"], + realLLM: true, + }, { id: "ext:finalizer-isolation", package: "deepagent-code", @@ -242,6 +248,12 @@ export const suites: Suite[] = [ command: ["bun", "run", "test:llm-live:steer-boundary"], realLLM: true, }, + { + id: "live:activity-progress", + package: "deepagent-code", + command: ["bun", "run", "test:llm-live:activity-progress"], + realLLM: true, + }, { id: "ext:subagent-worktree", package: "deepagent-code", @@ -377,6 +389,14 @@ export const suites: Suite[] = [ realLLM: false, desktop: true, }, + { + id: "live:desktop-activity-progress-restart", + package: "desktop", + command: ["node", "--experimental-strip-types", "./scripts/live-llm/activity-progress-restart.ts"], + realLLM: true, + desktop: true, + packageScript: "test:llm-live:activity-progress-restart", + }, { id: "ext:desktop-sidecar", package: "desktop", @@ -401,6 +421,14 @@ export const suites: Suite[] = [ desktop: true, packageScript: "test:llm-release:ui", }, + { + id: "release:desktop-activity-progress-package", + package: "desktop", + command: ["bun", "run", "test:llm-release:activity-progress-package"], + realLLM: true, + desktop: true, + packageScript: "test:llm-release:activity-progress-package", + }, { id: "release:desktop-long-session", package: "desktop",