diff --git a/DEVLOG.md b/DEVLOG.md index 388f3057..9a0df2e0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,16 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-16 — Native Sessions production initialization hotfix + +The authenticated v0.26.0 live smoke reached Forge but the first native Hermes +send failed before contacting the runtime: the durable outbound delivery +transaction serialized `systemPrompt` before that block-scoped value was +initialized later in the handler. The prompt and optional canvas context are +now prepared before the transaction, preserving atomic user-message/outbox +writes without a production-bundle temporal-dead-zone failure. A focused +source-order regression protects this initialization invariant. + ## 2026-07-16 — v0.26.0 coordinated release preparation Release coordination confirmed that AXI-113 merged after v0.25.0 with no other @@ -119,6 +129,7 @@ crash-stranded `PROCESSING` row. The platform plugin also retains a failed final draft and reuses its original event id, preventing duplicate final messages on retry. Focused review coverage passes **33/33** TypeScript tests and **8/8** Python adapter tests. + ## 2026-07-16 — AXI-86 Artifact Studio Implemented Forge as the artifact control plane for people and agents. The @@ -482,6 +493,7 @@ Verification: focused dispatcher and operator-attention coverage passed passed; the full Vitest suite passed (**1,211 passed; 1 skipped**); a fresh production build and the responsive Command Center Playwright contract passed (**1/1** across desktop, tablet, and mobile); and `git diff --check` passed. + ## 2026-07-13 — Artifacts system research and implementation brief Created a documentation-only Artifacts audit in an isolated worktree using diff --git a/src/app/api/chat/stream/route.ts b/src/app/api/chat/stream/route.ts index ec7ec7d2..5bc9fee9 100644 --- a/src/app/api/chat/stream/route.ts +++ b/src/app/api/chat/stream/route.ts @@ -563,6 +563,54 @@ export async function POST(req: NextRequest) { ] : body; + // Prepare connector instructions before the durable delivery transaction. + // Hermes delivery rows persist this value atomically with the user turn so + // replay has the exact original instructions. Keeping it below the + // transaction created a temporal-dead-zone failure only on the native + // Sessions path (`systemPrompt` was initialized later while the callback + // tried to serialize it). + let boundCanvasId: string | null = null; + if (canvasId) { + const canvas = await db.workspaceCanvas.findFirst({ + where: { id: canvasId, workspaceId, archivedAt: null }, + select: { id: true }, + }); + if (canvas) boundCanvasId = canvas.id; + } + + const baseSystemPrompt = + `You are ${agent.name}. You're chatting with the operator inside Forge, a project ` + + `management workspace. Be concise and direct. ` + + (agent.capabilities && agent.capabilities.length > 0 + ? `Your capabilities: ${agent.capabilities.join(", ")}.\n\n` + : "") + + (agent.templateMarkdown ? `${agent.templateMarkdown}\n` : ""); + + const buildSystemPrompt = async (): Promise => { + if (!boundCanvasId) return baseSystemPrompt; + const canvasSummary = await loadCanvasContextSummary(workspaceId, boundCanvasId); + const storyboardHint = + `When the operator asks you to lay out, storyboard, sketch, ` + + `organize, or "set up a canvas for" something, reach for the ` + + `compound MCP gestures:\n` + + `- \`canvases.storyboardPlan({ planId })\` — labeled frame with ` + + `the plan card + notes lane + sources column + next-steps lane.\n` + + `- \`canvases.storyboardIssue({ issueId })\` — labeled frame ` + + `with the issue card + related + comments + attachments.\n` + + `- \`canvases.storyboardResearch({ topic })\` — labeled frame ` + + `with a scratchpad + sources column + next-steps lane.\n` + + `- \`canvases.storyboardCustom({ name, panels })\` — escape ` + + `hatch when the three presets don't fit. Provide a panels[] ` + + `array of \`{ label, body?, x, y, width, height }\`.\n` + + `One storyboard call is the grammar — don't scatter 25 floating ` + + `nodes by hand.`; + if (!canvasSummary) return `${baseSystemPrompt}\n\n${storyboardHint}`; + return `${baseSystemPrompt}\n\n${canvasSummary}\n\n${storyboardHint}`; + }; + // Dispatch-only agents receive the durable user event and build their own + // runtime context. Avoid reading canvas state for a prompt Forge never uses. + const systemPrompt = useDispatch ? "" : await buildSystemPrompt(); + // Persist the USER row and the audit/event in one transaction. Mirrors // `chat.send` so the inbox lifecycle stays honest. Forge-owned streaming // turns tag the payload `streamed: true` so the dispatch worker can no-op @@ -873,48 +921,6 @@ export async function POST(req: NextRequest) { }); const history = recent.reverse(); - // Resolve the canvas binding once up-front so an invalid id fails fast - // (rather than silently dropping the binding mid-stream). - let boundCanvasId: string | null = null; - if (canvasId) { - const canvas = await db.workspaceCanvas.findFirst({ - where: { id: canvasId, workspaceId, archivedAt: null }, - select: { id: true }, - }); - if (canvas) boundCanvasId = canvas.id; - } - - const baseSystemPrompt = - `You are ${agent.name}. You're chatting with the operator inside Forge, a project ` + - `management workspace. Be concise and direct. ` + - (agent.capabilities && agent.capabilities.length > 0 - ? `Your capabilities: ${agent.capabilities.join(", ")}.\n\n` - : "") + - (agent.templateMarkdown ? `${agent.templateMarkdown}\n` : ""); - - const buildSystemPrompt = async (): Promise => { - if (!boundCanvasId) return baseSystemPrompt; - const canvasSummary = await loadCanvasContextSummary(workspaceId, boundCanvasId); - const storyboardHint = - `When the operator asks you to lay out, storyboard, sketch, ` + - `organize, or "set up a canvas for" something, reach for the ` + - `compound MCP gestures:\n` + - `- \`canvases.storyboardPlan({ planId })\` — labeled frame with ` + - `the plan card + notes lane + sources column + next-steps lane.\n` + - `- \`canvases.storyboardIssue({ issueId })\` — labeled frame ` + - `with the issue card + related + comments + attachments.\n` + - `- \`canvases.storyboardResearch({ topic })\` — labeled frame ` + - `with a scratchpad + sources column + next-steps lane.\n` + - `- \`canvases.storyboardCustom({ name, panels })\` — escape ` + - `hatch when the three presets don't fit. Provide a panels[] ` + - `array of \`{ label, body?, x, y, width, height }\`.\n` + - `One storyboard call is the grammar — don't scatter 25 floating ` + - `nodes by hand.`; - if (!canvasSummary) return `${baseSystemPrompt}\n\n${storyboardHint}`; - return `${baseSystemPrompt}\n\n${canvasSummary}\n\n${storyboardHint}`; - }; - const systemPrompt = await buildSystemPrompt(); - // Build the model-facing message array. History rows stay plain text — // we only attach image blocks to the *latest* user turn, which is the // one we just persisted. (Historical attachments aren't replayed; the @@ -933,7 +939,7 @@ export async function POST(req: NextRequest) { messages.push({ role, content: m.body }); } } - return { boundCanvasId, buildSystemPrompt, history, messages, systemPrompt }; + return { history, messages }; })().catch(async (err) => { const failedAt = new Date().toISOString(); const message = streamErrorMessage(err, "Failed to prepare the chat turn."); @@ -963,7 +969,7 @@ export async function POST(req: NextRequest) { if (!prepared) { return NextResponse.json({ error: "Failed to prepare chat turn" }, { status: 500 }); } - const { boundCanvasId, buildSystemPrompt, history, messages, systemPrompt } = prepared; + const { history, messages } = prepared; const encoder = new TextEncoder(); const stream = new ReadableStream({ diff --git a/tests/unit/chat-stream-initialization.test.ts b/tests/unit/chat-stream-initialization.test.ts new file mode 100644 index 00000000..0a325599 --- /dev/null +++ b/tests/unit/chat-stream-initialization.test.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("native chat stream initialization", () => { + it("prepares persisted Hermes instructions before the delivery transaction", async () => { + const source = await readFile( + path.join(process.cwd(), "src/app/api/chat/stream/route.ts"), + "utf8", + ); + const promptInitialization = source.indexOf( + 'const systemPrompt = useDispatch ? "" : await buildSystemPrompt();', + ); + const deliveryTransaction = source.indexOf("await tx.connectorDelivery.create({"); + + expect(promptInitialization).toBeGreaterThan(-1); + expect(deliveryTransaction).toBeGreaterThan(-1); + expect(promptInitialization).toBeLessThan(deliveryTransaction); + }); +});