Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/decisions/0005-conversational-authored-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/axi/agent-command-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions src/lib/agent-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
() => {
Expand Down
99 changes: 79 additions & 20 deletions src/lib/agent-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentWorkspaceSnapshot>;
readonly changes: Stream.Stream<AgentWorkspaceSnapshot>;
Expand Down Expand Up @@ -165,6 +169,10 @@ export interface AgentWorkspaceShape {
document: InterfaceDocument,
visibleInstruction?: string,
) => Effect.Effect<ShaperTurnOutcome, AgentWorkspaceError>;
readonly concludeShaperTurn: (
operation: OperationContext,
conclusion: ShaperTurnConclusion,
) => Effect.Effect<void>;
readonly cancel: (
role: InteractiveAgentRole,
) => Effect.Effect<void, FlectUnavailableError>;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
) {
Expand Down Expand Up @@ -1954,6 +2012,7 @@ export const AgentWorkspaceLive = Layer.effect(
submitAppPrompt,
submitPreviewPrompt,
submitShaperInstruction,
concludeShaperTurn,
cancel,
cancelPreview: cancelPreview(),
releasePreview: releasePreview(),
Expand Down
Loading
Loading