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",