diff --git a/docs/decisions/0005-conversational-authored-apps.md b/docs/decisions/0005-conversational-authored-apps.md index f32edc8..9b4a14d 100644 --- a/docs/decisions/0005-conversational-authored-apps.md +++ b/docs/decisions/0005-conversational-authored-apps.md @@ -81,7 +81,13 @@ last-known-good. through the guarded proposal build before automatic acceptance, so their build progress remains visible; the transient preview state they publish is a known cosmetic limitation. -- The Shaper conversation currently reports "Change complete" when the turn's - proposal latches, slightly before the controller finishes acceptance; a - staging failure after that message surfaces only as a failed operation. - Tightening that sequencing is follow-up work. +- The Shaper conversation confirms an authored app only after acceptance. + The turn latches the archive without claiming completion; the controller + then concludes the turn through the agent workspace: "Change complete" is + appended once staging and local acceptance succeed, a staging failure + appends a bounded "The app could not be activated" message and sets the + shaper status to error while the canvas stays last-known-good, and a + blocked review announces nothing because the explicit Activate/Discard + candidate takes over. Declarative document outcomes keep their latch-time + completion message; their acceptance is the immediate local revision that + follows in the same operation. diff --git a/src/axi/agent-command-bridge.test.ts b/src/axi/agent-command-bridge.test.ts index 629ff8d..6f66a9a 100644 --- a/src/axi/agent-command-bridge.test.ts +++ b/src/axi/agent-command-bridge.test.ts @@ -171,6 +171,7 @@ const makeLayer = () => { submitPreviewPrompt: () => Effect.succeed(AgentPromptOutcome.make({})), submitShaperInstruction: (_operation, _instruction, document) => Effect.succeed({ kind: "document", document }), + concludeShaperTurn: () => Effect.void, cancel: () => Effect.void, cancelPreview: Effect.void, releasePreview: Effect.void, diff --git a/src/lib/agent-workspace.test.ts b/src/lib/agent-workspace.test.ts index cf195b4..dc6b2fa 100644 --- a/src/lib/agent-workspace.test.ts +++ b/src/lib/agent-workspace.test.ts @@ -198,6 +198,49 @@ const makeProposalBridge = (document: InterfaceDocument) => { }; }; +const makeAppProposalBridge = (archive: Uint8Array, name: string) => { + let propose: AgentWorkspaceShape["proposeShaperApp"] | undefined; + return { + connect: (workspace: AgentWorkspaceShape) => { + propose = workspace.proposeShaperApp; + }, + execute: ((role, _line, options) => + Effect.gen(function* () { + const submit = propose; + if (submit === undefined || options?.agentContext === undefined) { + return yield* Effect.fail( + BunCommandFailed.make({ + reason: "execution", + message: "The test Shaper app bridge is unavailable.", + }), + ); + } + yield* submit( + AgentCommandSource.make({ + kind: "agent", + role: role === "previewApp" ? "app" : role, + ...options.agentContext, + }), + archive, + name, + ).pipe( + Effect.mapError(() => + BunCommandFailed.make({ + reason: "execution", + message: "The test app proposal was rejected.", + }), + ), + ); + return BunCommandResult.make({ + version: 1, + exitCode: 0, + stdout: "status: proposed\n", + stderr: "", + }); + })) satisfies SandboxedShellShape["execute"], + }; +}; + describe("AgentWorkspace", () => { it.effect( "returns a typed edit request without treating questions as Shape", @@ -838,6 +881,116 @@ describe("AgentWorkspace", () => { }, ); + it.effect( + "confirms an authored app only when the controller concludes the turn", + () => { + const archive = new Uint8Array([1, 2, 3]); + const bridge = makeAppProposalBridge(archive, "Driftwood Coffee"); + const { layer } = makeLayer({ + shape: () => + Stream.make( + AgentShellRequest.make({ + type: "shell_request", + requestId: "shell-app-proposal", + command: "flect app propose /workspace/project", + }), + ShapeCompleted.make({ type: "shape_completed" }), + ), + execute: bridge.execute, + }); + return Effect.gen(function* () { + const workspace = yield* AgentWorkspace; + bridge.connect(workspace); + yield* workspace.refresh; + const outcome = yield* workspace.submitShaperInstruction( + userOperation(30), + "Make a landing page website", + defaultInterfaceDocument, + ); + const afterTurn = yield* workspace.snapshot; + + assert.deepStrictEqual(outcome, { + kind: "app", + archive, + name: "Driftwood Coffee", + }); + assert.deepStrictEqual( + afterTurn.shaper.messages.map((message) => message.content), + ["Make a landing page website"], + ); + assert.strictEqual(afterTurn.shaper.status, "ready"); + + yield* workspace.concludeShaperTurn(userOperation(30), { + kind: "completed", + name: "Driftwood Coffee", + }); + const confirmed = yield* workspace.snapshot; + + assert.deepStrictEqual( + confirmed.shaper.messages.map((message) => message.content), + ["Make a landing page website", "Change complete: Driftwood Coffee"], + ); + assert.deepStrictEqual( + confirmed.shaper.messages.map((message) => message.turnId), + ["operation-agent-workspace-30", "operation-agent-workspace-30"], + ); + assert.strictEqual(confirmed.shaper.status, "ready"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "reports a bounded activation failure without any completion claim", + () => { + const archive = new Uint8Array([4, 5, 6]); + const bridge = makeAppProposalBridge(archive, "Driftwood Coffee"); + const { layer } = makeLayer({ + shape: () => + Stream.make( + AgentShellRequest.make({ + type: "shell_request", + requestId: "shell-app-proposal-failure", + command: "flect app propose /workspace/project", + }), + ShapeCompleted.make({ type: "shape_completed" }), + ), + execute: bridge.execute, + }); + return Effect.gen(function* () { + const workspace = yield* AgentWorkspace; + bridge.connect(workspace); + yield* workspace.refresh; + yield* workspace.submitShaperInstruction( + userOperation(31), + "Make a landing page website", + defaultInterfaceDocument, + ); + yield* workspace.concludeShaperTurn(userOperation(31), { + kind: "failed", + reason: "The authored project could not be checkpointed safely.", + }); + const snapshot = yield* workspace.snapshot; + + assert.deepStrictEqual( + snapshot.shaper.messages.map((message) => message.content), + [ + "Make a landing page website", + "The app could not be activated: The authored project could not be checkpointed safely. Your previous canvas is unchanged.", + ], + ); + assert.notInclude( + snapshot.shaper.messages.map((message) => message.content).join("\n"), + "Change complete", + ); + assert.strictEqual(snapshot.shaper.status, "error"); + assert.strictEqual( + snapshot.shaper.error, + "The authored project could not be checkpointed safely.", + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect( "cancels a running role through the runtime and its Effect fiber", () => { diff --git a/src/lib/agent-workspace.ts b/src/lib/agent-workspace.ts index ae0133a..10b1ddd 100644 --- a/src/lib/agent-workspace.ts +++ b/src/lib/agent-workspace.ts @@ -91,6 +91,10 @@ export type ShaperTurnOutcome = readonly name: string; }; +export type ShaperTurnConclusion = + | { readonly kind: "completed"; readonly name: string } + | { readonly kind: "failed"; readonly reason: string }; + export interface AgentWorkspaceShape { readonly snapshot: Effect.Effect; readonly changes: Stream.Stream; @@ -165,6 +169,10 @@ export interface AgentWorkspaceShape { document: InterfaceDocument, visibleInstruction?: string, ) => Effect.Effect; + readonly concludeShaperTurn: ( + operation: OperationContext, + conclusion: ShaperTurnConclusion, + ) => Effect.Effect; readonly cancel: ( role: InteractiveAgentRole, ) => Effect.Effect; @@ -1779,26 +1787,30 @@ export const AgentWorkspaceLive = Layer.effect( latched = yield* latchedOutcome(); } const candidate = latched ?? (yield* Effect.fail(unavailable())); - const candidateName = - candidate.kind === "document" - ? candidate.document.name - : candidate.name; - const now = yield* Clock.currentTimeMillis; - yield* updateRole("shaper", (current) => - RoleConversationSnapshot.make({ - ...requiredRoleFields(current), - status: "ready", - messages: boundedMessages(current.messages, [ - message( - "assistant", - `Change complete: ${candidateName}`, - operation.source, - now, - operation.operationId, - ), - ]), - }), - ); + if (candidate.kind === "document") { + const now = yield* Clock.currentTimeMillis; + yield* updateRole("shaper", (current) => + RoleConversationSnapshot.make({ + ...requiredRoleFields(current), + status: "ready", + messages: boundedMessages(current.messages, [ + message( + "assistant", + `Change complete: ${candidate.document.name}`, + operation.source, + now, + operation.operationId, + ), + ]), + }), + ); + } else { + // An authored app is accepted by the controller after this turn + // returns. The controller confirms success or failure through + // concludeShaperTurn, so the conversation never claims completion + // before the canvas actually changed. + yield* setOperationalStatus("shaper", "ready"); + } yield* appendJournal(operation, { category: "turn", phase: "succeeded", @@ -1832,6 +1844,52 @@ export const AgentWorkspaceLive = Layer.effect( ); }); + const concludeShaperTurn = Effect.fn( + "Flect.AgentWorkspace.concludeShaperTurn", + )(function* ( + operation: OperationContext, + conclusion: ShaperTurnConclusion, + ) { + const now = yield* Clock.currentTimeMillis; + if (conclusion.kind === "completed") { + yield* updateRole("shaper", (current) => + RoleConversationSnapshot.make({ + ...requiredRoleFields(current), + status: "ready", + messages: boundedMessages(current.messages, [ + message( + "assistant", + `Change complete: ${conclusion.name}`, + operation.source, + now, + operation.operationId, + ), + ]), + }), + ); + return; + } + const reason = + conclusion.reason.trim().slice(0, 300) || + "The change could not be applied safely."; + yield* updateRole("shaper", (current) => + RoleConversationSnapshot.make({ + ...requiredRoleFields(current), + status: "error", + error: reason, + messages: boundedMessages(current.messages, [ + message( + "assistant", + `The app could not be activated: ${reason} Your previous canvas is unchanged.`, + operation.source, + now, + operation.operationId, + ), + ]), + }), + ); + }); + const cancel = Effect.fn("Flect.AgentWorkspace.cancel")(function* ( role: InteractiveAgentRole, ) { @@ -1954,6 +2012,7 @@ export const AgentWorkspaceLive = Layer.effect( submitAppPrompt, submitPreviewPrompt, submitShaperInstruction, + concludeShaperTurn, cancel, cancelPreview: cancelPreview(), releasePreview: releasePreview(), diff --git a/src/lib/workspace-controller.test.ts b/src/lib/workspace-controller.test.ts index aa0f847..6c5c82b 100644 --- a/src/lib/workspace-controller.test.ts +++ b/src/lib/workspace-controller.test.ts @@ -67,6 +67,7 @@ import { WorkbenchHandoff, } from "../../shared/control"; import { PortableExtensionPackage } from "../../shared/extensions"; +import { GitWorkspaceFailure } from "../../shared/git-workspace"; import { defaultInterfaceDocument, InterfaceDocument, @@ -221,6 +222,9 @@ const makeLayer = (options?: { }), }), ); + const concludeShaperTurn = vi.fn( + () => Effect.void, + ); const agentLayer = Layer.effect( AgentWorkspace, Effect.gen(function* () { @@ -248,6 +252,7 @@ const makeLayer = (options?: { submitAppPrompt, submitPreviewPrompt, submitShaperInstruction, + concludeShaperTurn, cancel: () => Effect.void, cancelPreview: Effect.void, releasePreview: Effect.void, @@ -316,6 +321,7 @@ const makeLayer = (options?: { submitPreviewPrompt, submitAppPrompt, submitShaperInstruction, + concludeShaperTurn, }; }; @@ -2712,7 +2718,8 @@ describe("FlectWorkspaceController", () => { it.effect( "accepts a conversationally authored web app as a local revision without ceremony", () => { - const { layer, submitShaperInstruction } = makeLayer(); + const { layer, submitShaperInstruction, concludeShaperTurn } = + makeLayer(); return Effect.gen(function* () { const authored = yield* importWebProject( [ @@ -2768,6 +2775,116 @@ describe("FlectWorkspaceController", () => { presentation.acceptedReview?.id, "local.flect.driftwood-coffee", ); + assert.deepStrictEqual( + concludeShaperTurn.mock.calls.map(([, conclusion]) => conclusion), + [{ kind: "completed", name: "Driftwood Coffee" }], + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "reports a bounded failure instead of completion when authored-app staging fails", + () => { + const unexpected = Effect.die(new Error("Unexpected Git call")); + const git: GitWorkspaceShape = { + open: () => + Effect.succeed({ + type: "opened", + variant: "asyncify" as const, + existed: true, + }), + write: () => unexpected, + read: () => unexpected, + run: () => unexpected, + exportRepository: unexpected, + remove: unexpected, + checkpoint: () => + Effect.fail( + GitWorkspaceFailure.make({ + operation: "checkpoint", + reason: "conflict", + message: "The authoring branch moved unexpectedly.", + }), + ), + readAtRef: () => unexpected, + moveRef: () => unexpected, + snapshotRef: () => unexpected, + status: () => + Effect.succeed({ + type: "status", + acceptedCommit: "a".repeat(40), + lastKnownGoodCommit: "b".repeat(40), + conflictPaths: [], + dirty: false, + }), + importRepository: () => unexpected, + importObjects: () => unexpected, + deleteRef: () => unexpected, + inspectCommit: () => unexpected, + mergeRef: () => unexpected, + inspectShare: () => unexpected, + }; + const { layer, submitShaperInstruction, concludeShaperTurn } = makeLayer({ + git, + }); + return Effect.gen(function* () { + const authored = yield* importWebProject( + [ + { + path: "index.html", + contents: new TextEncoder().encode( + "

Driftwood Coffee

", + ), + }, + ], + { + source: "conversation", + revision: "conversation", + name: "Driftwood Coffee", + }, + ); + submitShaperInstruction.mockImplementationOnce(() => + Effect.succeed({ + kind: "app", + archive: authored.archive, + name: authored.report.name, + }), + ); + const controller = yield* FlectWorkspaceController; + const before = yield* controller.snapshot; + const result = yield* Effect.result( + controller.dispatch( + envelope( + 802, + SubmitShaperInstruction.make({ + type: "submit-shaper-instruction", + instruction: "Make a landing page website", + }), + ), + ), + ); + const snapshot = yield* controller.snapshot; + const presentation = yield* controller.capsulePresentation ?? + Effect.succeed({}); + + assert.strictEqual(result._tag, "Failure"); + assert.deepStrictEqual( + concludeShaperTurn.mock.calls.map(([, conclusion]) => conclusion), + [ + { + kind: "failed", + reason: "The authored project could not be checkpointed safely.", + }, + ], + ); + assert.strictEqual(snapshot.shaping.proposal, undefined); + assert.deepStrictEqual( + snapshot.shaping.active.document, + before.shaping.active.document, + ); + assert.strictEqual(presentation.accepted, undefined); + assert.strictEqual(presentation.candidateReview, undefined); }).pipe(Effect.provide(layer)); }, ); diff --git a/src/lib/workspace-controller.ts b/src/lib/workspace-controller.ts index 9be272a..9af009f 100644 --- a/src/lib/workspace-controller.ts +++ b/src/lib/workspace-controller.ts @@ -534,6 +534,19 @@ const documentHasNode = (document: InterfaceDocument, nodeId: string) => { const commandRejected = (message: string) => CommandRejected.make({ message }); +const boundedFailureReason = (error: unknown): string => { + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message.trim().slice(0, 300); + } + return "The change could not be applied safely."; +}; + const modeFromWorkbench = ( workbench: WorkbenchSnapshot, shaping: ShapingSnapshot, @@ -2442,17 +2455,49 @@ export const FlectWorkspaceControllerLive = Layer.effect( : { lastKnownGoodReview: current.lastKnownGoodReview }), })); if (outcome.kind === "app") { - if (shaping.proposal !== undefined) { - return yield* Effect.fail( - commandRejected( - "Resolve the current preview before authoring an app.", - ), + // The Shaper turn latched the authored archive without claiming + // completion; the conversation stays truthful by confirming only + // after acceptance below succeeds, and by reporting a bounded + // failure while the canvas stays last-known-good otherwise. + const operation = operationContext(envelope, operationId); + yield* Effect.gen(function* () { + if (shaping.proposal !== undefined) { + return yield* Effect.fail( + commandRejected( + "Resolve the current preview before authoring an app.", + ), + ); + } + yield* stageCapsuleProposal( + envelope, + operationId, + outcome.archive, + { + proposer: "shaper", + finalize: "local", + }, ); + }).pipe( + Effect.tapError((error) => + agent + .concludeShaperTurn(operation, { + kind: "failed", + reason: boundedFailureReason(error), + }) + .pipe(Effect.andThen(syncAgentProjection())), + ), + ); + const staged = yield* SubscriptionRef.get(capsulePresentation); + if (staged.candidateReview === undefined) { + // A blocked review falls back to the explicit Activate/Discard + // candidate instead of accepting, so it must not be announced + // as a completed change. + yield* agent.concludeShaperTurn(operation, { + kind: "completed", + name: outcome.name, + }); + yield* syncAgentProjection(); } - yield* stageCapsuleProposal(envelope, operationId, outcome.archive, { - proposer: "shaper", - finalize: "local", - }); return; } const candidate = outcome.document;