diff --git a/.harness/checklists/theorem-chat-register--plan-local-20260805a.json b/.harness/checklists/theorem-chat-register--plan-local-20260805a.json index 36db6f01..f849f42c 100644 --- a/.harness/checklists/theorem-chat-register--plan-local-20260805a.json +++ b/.harness/checklists/theorem-chat-register--plan-local-20260805a.json @@ -152,7 +152,7 @@ "No GitHub Copilot sign-in wall for chat" ], "proof_command": "manual signed-in /IDE session smoke + ACP prompt receipt", - "evidence_note": "Workspace deploy a0f49841 SUCCESS (81a3ada1 / PR #196). Live /opt/commonplace/extensions/theorem-vscode has theorem.openChat + theorem.chat in dist bundles; studio-server extensions list theorem-vscode only (no copilot)." + "evidence_note": "Workspace deploy a0f49841 SUCCESS (81a3ada1 / PR #196). Live pack theorem.openChat + theorem.chat. Spec-review Pass B: ACP agent_message_chunk \u2192 onDelta; Pass C: openChat hidden unless THEOREM_ACP_WS_URL/theorem.agentUrl. Interactive signed-in panel one-turn still optional human smoke." }, { "id": "CR-006", diff --git a/apps/theorem-vscode/package.json b/apps/theorem-vscode/package.json index 5268e68b..2bd102a2 100644 --- a/apps/theorem-vscode/package.json +++ b/apps/theorem-vscode/package.json @@ -88,6 +88,7 @@ ], "menus": { "commandPalette": [ + { "command": "theorem.openChat", "when": "theorem.acpConfigured" }, { "command": "theorem.applyFix", "when": "false" }, { "command": "theorem.diffRevision", "when": "false" }, { "command": "theorem.restoreRevision", "when": "false" }, diff --git a/apps/theorem-vscode/src/agent/acp-chunks.ts b/apps/theorem-vscode/src/agent/acp-chunks.ts new file mode 100644 index 00000000..e4f9ffc8 --- /dev/null +++ b/apps/theorem-vscode/src/agent/acp-chunks.ts @@ -0,0 +1,17 @@ +// SOURCING: @commonplace/theorem-acp/state. Pure filter for Studio chat deltas. +import { agentMessageTextFromUpdate, type AcpSessionUpdate } from '@commonplace/theorem-acp/state'; + +/** + * Forward agent_message_chunk text for the active session into a ChatTransport + * onDelta callback. Ignores other update kinds and other sessions. + */ +export function forwardAgentMessageChunk( + sessionId: string, + notification: { readonly sessionId: string; readonly update: AcpSessionUpdate }, + onDelta: (chunk: string) => void, +): void { + if (notification.sessionId !== sessionId) return; + if (notification.update.sessionUpdate !== 'agent_message_chunk') return; + const chunk = agentMessageTextFromUpdate(notification.update); + if (chunk) onDelta(chunk); +} diff --git a/apps/theorem-vscode/src/agent/chat-panel.ts b/apps/theorem-vscode/src/agent/chat-panel.ts index 6f120da2..b81a280f 100644 --- a/apps/theorem-vscode/src/agent/chat-panel.ts +++ b/apps/theorem-vscode/src/agent/chat-panel.ts @@ -91,8 +91,7 @@ export class TheoremChatPanel { }, prompt: async (_sessionId, text, onDelta) => { if (!session) throw new Error('theorem.chat: no ACP session'); - await session.prompt(text); - onDelta('Turn submitted over Theorem ACP.'); + await session.prompt(text, onDelta); }, dispose: () => { session?.dispose(); diff --git a/apps/theorem-vscode/src/agent/presence.ts b/apps/theorem-vscode/src/agent/presence.ts index 76519050..ed660152 100644 --- a/apps/theorem-vscode/src/agent/presence.ts +++ b/apps/theorem-vscode/src/agent/presence.ts @@ -25,7 +25,11 @@ export interface PresenceConfig { /** The subset of the ACP session surface the IDE end drives. */ export interface AcpSession { readonly sessionId: string; - prompt(text: string): Promise; + /** + * Send one user turn. When `onDelta` is provided, agent_message_chunk text + * from session/update notifications is forwarded as it arrives. + */ + prompt(text: string, onDelta?: (chunk: string) => void): Promise; onPermissionRequest(handler: (request: PermissionRequest) => Promise): void; dispose(): void; } diff --git a/apps/theorem-vscode/src/agent/session-opener.ts b/apps/theorem-vscode/src/agent/session-opener.ts index f407f6ed..2b9f926b 100644 --- a/apps/theorem-vscode/src/agent/session-opener.ts +++ b/apps/theorem-vscode/src/agent/session-opener.ts @@ -9,6 +9,7 @@ import type { TheoremPackConfig } from '../config'; import type { AcpSession, PermissionOutcome, PermissionRequest } from './presence'; +import { forwardAgentMessageChunk } from './acp-chunks'; /** * Open the window's session. @@ -47,8 +48,17 @@ export async function openIdeSession( return { sessionId, - prompt: async (text) => { - await client.prompt(sessionId, text); + prompt: async (text, onDelta) => { + const unsub = onDelta + ? client.onSessionUpdate((notification) => { + forwardAgentMessageChunk(sessionId, notification, onDelta); + }) + : undefined; + try { + await client.prompt(sessionId, text); + } finally { + unsub?.(); + } }, onPermissionRequest: (handler) => { client.onRequestPermission(async (request) => { diff --git a/apps/theorem-vscode/src/config.ts b/apps/theorem-vscode/src/config.ts index b4d2c4c2..c93f1953 100644 --- a/apps/theorem-vscode/src/config.ts +++ b/apps/theorem-vscode/src/config.ts @@ -10,6 +10,12 @@ export interface TheoremPackConfig { readonly consoleOrigin: string; readonly agentUrl: string; readonly token?: string; + /** + * True when THEOREM_ACP_WS_URL or theorem.agentUrl is set. SPEC rollback: + * hide Theorem: Open Chat when ACP URL is unset (consoleOrigin alone is not + * an explicit ACP door). + */ + readonly acpConfigured: boolean; } function env(name: string): string | undefined { @@ -47,10 +53,11 @@ export function resolveTheoremPackConfig(config: WorkspaceConfiguration): Theore ?? config.get('consoleOrigin') ?? 'https://v2.theoremharness.com'; - const agentUrl = + const explicitAgentUrl = env('THEOREM_ACP_WS_URL') - ?? config.get('agentUrl') - ?? consoleOrigin; + ?? (config.get('agentUrl')?.trim() || undefined); + const acpConfigured = Boolean(explicitAgentUrl); + const agentUrl = explicitAgentUrl ?? consoleOrigin; const token = env('THEOREM_EDITOR_API_KEY') @@ -63,6 +70,7 @@ export function resolveTheoremPackConfig(config: WorkspaceConfiguration): Theore ...(projectId ? { projectId } : {}), consoleOrigin, agentUrl, + acpConfigured, ...(token ? { token } : {}), }; } diff --git a/apps/theorem-vscode/src/extension.ts b/apps/theorem-vscode/src/extension.ts index 7ce99e65..b8ba030b 100644 --- a/apps/theorem-vscode/src/extension.ts +++ b/apps/theorem-vscode/src/extension.ts @@ -39,6 +39,10 @@ export function activate(context: vscode.ExtensionContext): void { const output = vscode.window.createOutputChannel('Theorem'); const config = vscode.workspace.getConfiguration('theorem'); const resolved = resolveTheoremPackConfig(config); + void vscode.commands.executeCommand('setContext', 'theorem.acpConfigured', resolved.acpConfigured); + if (!resolved.acpConfigured) { + output.appendLine('chat: ACP URL unset (set THEOREM_ACP_WS_URL or theorem.agentUrl); Theorem: Open Chat hidden'); + } const client = new SubstrateClient({ endpoint: { @@ -194,7 +198,15 @@ export function activate(context: vscode.ExtensionContext): void { }, ), vscode.commands.registerCommand('theorem.startSession', () => presence.start()), - vscode.commands.registerCommand('theorem.openChat', () => chatPanel.show()), + vscode.commands.registerCommand('theorem.openChat', () => { + if (!resolved.acpConfigured) { + void vscode.window.showWarningMessage( + 'Theorem Chat needs THEOREM_ACP_WS_URL or theorem.agentUrl before opening.', + ); + return; + } + chatPanel.show(); + }), vscode.commands.registerCommand('theorem.showHistory', async (target?: vscode.Uri) => { const uri = target ?? vscode.window.activeTextEditor?.document.uri; if (uri) await showHistoryQuickPick(timeline, uri); diff --git a/apps/theorem-vscode/test/acp-chat.test.ts b/apps/theorem-vscode/test/acp-chat.test.ts new file mode 100644 index 00000000..126c02ec --- /dev/null +++ b/apps/theorem-vscode/test/acp-chat.test.ts @@ -0,0 +1,84 @@ +// SOURCING: none. Spec-review completion Pass B/C — ACP chunk forward + acpConfigured. +import { describe, expect, it, vi } from 'vitest'; +import { forwardAgentMessageChunk } from '../src/agent/acp-chunks'; +import { resolveTheoremPackConfig } from '../src/config'; + +function fakeConfig(values: Record) { + return { + get(key: string): T | undefined { + return values[key] as T | undefined; + }, + }; +} + +describe('forwardAgentMessageChunk', () => { + it('forwards agent_message_chunk text for the active session', () => { + const onDelta = vi.fn(); + forwardAgentMessageChunk( + 'sess-1', + { + sessionId: 'sess-1', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + onDelta, + ); + expect(onDelta).toHaveBeenCalledWith('hello'); + }); + + it('ignores other sessions and update kinds', () => { + const onDelta = vi.fn(); + forwardAgentMessageChunk( + 'sess-1', + { + sessionId: 'other', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'nope' }, + }, + }, + onDelta, + ); + forwardAgentMessageChunk( + 'sess-1', + { + sessionId: 'sess-1', + update: { sessionUpdate: 'theorem_turn_activity', status: 'running' }, + }, + onDelta, + ); + expect(onDelta).not.toHaveBeenCalled(); + }); +}); + +describe('acpConfigured', () => { + it('is false when ACP URL is unset', () => { + const prev = process.env.THEOREM_ACP_WS_URL; + delete process.env.THEOREM_ACP_WS_URL; + try { + const pack = resolveTheoremPackConfig(fakeConfig({}) as never); + expect(pack.acpConfigured).toBe(false); + expect(pack.agentUrl).toBe('https://v2.theoremharness.com'); + } finally { + if (prev === undefined) delete process.env.THEOREM_ACP_WS_URL; + else process.env.THEOREM_ACP_WS_URL = prev; + } + }); + + it('is true when theorem.agentUrl is set', () => { + const prev = process.env.THEOREM_ACP_WS_URL; + delete process.env.THEOREM_ACP_WS_URL; + try { + const pack = resolveTheoremPackConfig( + fakeConfig({ agentUrl: 'wss://example/v1/commonplace/acp/ws' }) as never, + ); + expect(pack.acpConfigured).toBe(true); + expect(pack.agentUrl).toContain('acp/ws'); + } finally { + if (prev === undefined) delete process.env.THEOREM_ACP_WS_URL; + else process.env.THEOREM_ACP_WS_URL = prev; + } + }); +}); diff --git a/docs/plans/theorem-chat-register/EXECUTE-REPORT.md b/docs/plans/theorem-chat-register/EXECUTE-REPORT.md index 20d0d085..287bd027 100644 --- a/docs/plans/theorem-chat-register/EXECUTE-REPORT.md +++ b/docs/plans/theorem-chat-register/EXECUTE-REPORT.md @@ -3,10 +3,10 @@ Plan: `plan-theorem-chat-register-20260805a` ## Summary -- Final condition: Copilot retired; sticky `/workspace/repo` repaired; Theorem chat register package mounted at console `/chat` and in the live Studio pack as `Theorem: Open Chat`. -- Goal achieved: **yes** for CR-001..007 (live evidence for CR-005 is pack command + `theorem.chat` in seeded extension bundles on deploy `a0f49841`; interactive signed-in panel click still a human smoke if desired). -- Biggest remaining risk: rotate leaked Git/workspace tokens from prior Studio build/SSH surfaces; prefer GitHub-triggered deploys over `railway up` from repo root. -- Next action: optional signed-in `/IDE` → Command Palette → Theorem: Open Chat; archive OpenWork leftovers after `2026-09-01` (D2). +- Final condition: Copilot retired; sticky `/workspace/repo` repaired; Theorem chat register package mounted at console `/chat` and in the live Studio pack as `Theorem: Open Chat`. Spec-review completion pass closed Studio ACP streaming stub, ACP-URL command hide, and doc/health residual honesty. +- Goal achieved: **yes** for CR-001..007 product cutover; interactive signed-in `/IDE` Open Chat + authenticated `/chat` HTML stamp remain the strongest remaining live smokes (doctor accepts loginish for `/chat`). +- Biggest remaining risk: rotate leaked Git/workspace tokens from prior Studio build/SSH surfaces; OpenWork `:8787` still owns container HEALTHCHECK (not product `/chat`). +- Next action: optional signed-in `/IDE` → Theorem: Open Chat one-turn; signed-in `/chat` stamp screenshot. ## Checklist Reconciliation | ID | Task | Status | Evidence | Validation | Notes | @@ -14,34 +14,21 @@ Plan: `plan-theorem-chat-register-20260805a` | CR-000 | Plan board | done | PLAN + checklist | artifact | | | CR-001 | Copilot retirement | done | PR #190/#191; deploy `4e69fb85` | live | | | CR-002 | Volume repair | done | package.json + HEAD on volume | live | | -| CR-003 | Register contract | done | SPEC-THEOREM-CHAT-REGISTER-1.0 | artifact | | +| CR-003 | Register contract | done | SPEC-THEOREM-CHAT-REGISTER-1.0 | artifact | ChatTransport shape amended to shipped API | | CR-004 | Package | done | `@commonplace/theorem-chat-register` | 3/3 tests | | -| CR-005 | Studio mount | done | PR #196; deploy `a0f49841` SUCCESS | live pack `theorem.openChat` + `theorem.chat` in dist | prior `1537b625` FAILED missing COPY | -| CR-006 | `/chat` + OpenWork out | done | console `a4ae048d`; doctor 26/26 | live | rollback `CONSOLE_OPENWORK_CHAT_PROXY=1` | -| CR-007 | Debt + report | done | this file + supersession | artifact | D2 deferred | +| CR-005 | Studio mount | done | PR #196; deploy `a0f49841`; ACP chunk forward + `acpConfigured` hide | pack + unit tests | interactive panel smoke optional | +| CR-006 | `/chat` + OpenWork out | done | console `a4ae048d`; doctor 26/26 | live | auth HTML stamp optional | +| CR-007 | Debt + report | done | this file + supersession | artifact | `:8787` health residual recorded | -## Changes Made (final execute pass) -| Area | Files | Summary | Why | -|---|---|---|---| -| Workspace pack | `packaging/workspace/Dockerfile` | COPY `packages/theorem-chat-register` in `vscode-pack` | fix esbuild resolve | -| Watch | `packaging/workspace/railway.toml` | watch package path | rebuild on register edits | -| PRs | #196 merged; #195 closed | clean main-based fix | avoid add/add conflicts | - -## Validation -| Check | Result | Notes | +## Spec-review completion (Passes A–D) +| Pass | Change | Proof | |---|---|---| -| `pnpm --filter @commonplace/theorem-chat-register test` | pass | 3 tests | -| `pnpm --filter theorem-vscode build` | pass | local | -| `node scripts/doctor.mjs` | pass | 26/26 | -| Live console `/chat` | pass | no `openwork.chat` host | -| Live workspace deploy | pass | `a0f49841` SUCCESS @ `81a3ada1` | -| Live pack Open Chat | pass | `/opt/commonplace/extensions/theorem-vscode` contributes `theorem.openChat`; dist mentions `theorem.chat` ×9 | - -## Remaining Work -- What remains: optional interactive `/IDE` palette smoke; token rotation; D2 archive pass later. -- Why: pack/command contribution is the deploy oracle; UI click needs a signed-in browser session. -- Next step: rotate secrets; human smoke if wanted. +| B | Studio `session.prompt(..., onDelta)` forwards `agent_message_chunk` | `acp-chunks.ts` + `acp-chat.test.ts`; theorem-acp `agentMessageTextFromUpdate` | +| C | Hide `theorem.openChat` unless `THEOREM_ACP_WS_URL` / `theorem.agentUrl` | `acpConfigured` + package.json `when` | +| D | SPEC ChatTransport amend; PLAN CR-006 done; Dockerfile health comment; this report | docs | +| A | Live unauth `/chat` 307 login (no OpenWork proxy); pack `openChat` present | signed-in UI still human | ## Rollback - `CONSOLE_OPENWORK_CHAT_PROXY=1` restores `/chat` → workspace `:8787` OpenWork proxy. - Do not restore Copilot `defaultChatAgent` keys. +- Workspace HEALTHCHECK remains on OpenWork `:8787` until a follow-up moves it. diff --git a/docs/plans/theorem-chat-register/PLAN.md b/docs/plans/theorem-chat-register/PLAN.md index e9775a12..72d3745f 100644 --- a/docs/plans/theorem-chat-register/PLAN.md +++ b/docs/plans/theorem-chat-register/PLAN.md @@ -58,7 +58,7 @@ Authenticated users get: | CR-003 | Register contract: package path, `register_impl`, dual seams, retirement inventory | SPEC draft under this plan | named ids + non-goal on `IDefaultChatAgent` | **done** | | CR-004 | Implement Theorem chat register package | new package + pack/console adapters | package tests; no openworklabs/opencode on happy path | **done** | | CR-005 | Mount register in Studio agent/chat panel | theorem-vscode + Studio product settings | signed-in `/IDE` shows Theorem register; no Copilot sign-in wall | **done** (pack); live smoke after seed | -| CR-006 | Mount register at `/chat`; retire OpenWork door | `OpenworkChatRegister`, middleware, `.commonplace-canonical`, workspace image | stamp ≠ `openwork.chat`; doctor + register-manifest | **verifying** (await console deploy) | +| CR-006 | Mount register at `/chat`; retire OpenWork door | `OpenworkChatRegister`, middleware, `.commonplace-canonical`, workspace image | stamp ≠ `openwork.chat`; doctor + register-manifest | **done** | | CR-007 | Retire residual LLM / OpenWork surface debt + EXECUTE-REPORT | docs + deletions | EXECUTE-REPORT; no product route serves OpenWork as chat | **done** | ## Sequence diff --git a/docs/plans/theorem-chat-register/SPEC-THEOREM-CHAT-REGISTER-1.0.md b/docs/plans/theorem-chat-register/SPEC-THEOREM-CHAT-REGISTER-1.0.md index 5810a854..734f8784 100644 --- a/docs/plans/theorem-chat-register/SPEC-THEOREM-CHAT-REGISTER-1.0.md +++ b/docs/plans/theorem-chat-register/SPEC-THEOREM-CHAT-REGISTER-1.0.md @@ -22,7 +22,9 @@ must become **one** Theorem chat register. Two hosts, one package, one ### Public API (CR-004) - `REGISTER_IMPL = 'theorem.chat'` -- `ChatTransport`: `{ openSession(), prompt(text), subscribe(listener), dispose() }` +- `ChatTransport`: `{ openSession(), prompt(sessionId, text, onDelta), dispose() }` + - `onDelta` receives assistant text chunks as they arrive + - Snapshot `subscribe` lives on `createChatSessionController`, not on the transport - `createChatSessionController(transport)`: session open + one-turn prompt; pure logic, no React - `TheoremChatRegister`: React shell with `data-register-impl="theorem.chat"`, composer, message list @@ -66,4 +68,5 @@ until the pack implements VS Code Chat participant / host APIs. Overlay deletion ## 7. Rollback - Re-enable OpenWork middleware behind an explicit env (`CONSOLE_OPENWORK_CHAT_PROXY=1`) only as emergency rollback; default is Theorem register. -- Studio pack: hide Theorem Chat view command if ACP URL unset; do not restore Copilot product keys. +- Studio pack: hide Theorem Chat (`theorem.openChat`) from the command palette when ACP URL is unset (`THEOREM_ACP_WS_URL` / `theorem.agentUrl`); do not restore Copilot product keys. +- Workspace healthcheck may remain on OpenWork `:8787` until a follow-up moves health to the Studio IDE door; that residual is not the product `/chat` host. diff --git a/packages/theorem-acp/src/state.test.ts b/packages/theorem-acp/src/state.test.ts index 8357707d..a5f4a67c 100644 --- a/packages/theorem-acp/src/state.test.ts +++ b/packages/theorem-acp/src/state.test.ts @@ -9,6 +9,7 @@ import { } from './hosted-client.js'; import { TURN_CONTEXT_SCHEMA, + agentMessageTextFromUpdate, applySessionUpdate, beginTurn, cancelTurn, @@ -200,3 +201,17 @@ test('refusal remains terminal when a later completion is delivered', () => { assert.equal(replayed.turnStatus, 'refused'); }); + +test('agentMessageTextFromUpdate reads canonical ACP text chunks', () => { + assert.equal( + agentMessageTextFromUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'streamed' }, + }), + 'streamed', + ); + assert.equal( + agentMessageTextFromUpdate({ sessionUpdate: 'theorem_turn_activity', status: 'running' }), + '', + ); +}); diff --git a/packages/theorem-acp/src/state.ts b/packages/theorem-acp/src/state.ts index 94f118e2..9d711254 100644 --- a/packages/theorem-acp/src/state.ts +++ b/packages/theorem-acp/src/state.ts @@ -363,6 +363,11 @@ function isSettled(status: TheoremAgentState['turnStatus']): boolean { return status !== 'idle' && status !== 'running'; } +/** Extract assistant text from an ACP session update (agent_message_chunk). */ +export function agentMessageTextFromUpdate(update: AcpSessionUpdate): string { + return readText(update); +} + function readText(update: AcpSessionUpdate): string { const content = update.content as | { type?: string; text?: string; content?: { type?: string; text?: string } } diff --git a/packaging/workspace/Dockerfile b/packaging/workspace/Dockerfile index 7b52e4cc..f791c6c2 100644 --- a/packaging/workspace/Dockerfile +++ b/packaging/workspace/Dockerfile @@ -317,8 +317,9 @@ RUN mkdir -p "${WORKSPACE_DIR}" "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" "${OPENW EXPOSE 8787 8080 50090 -# The chat door is the healthcheck: it is the one the console proxies to, so -# an image that answers here is an image the console can actually use. +# The OpenWork `:8787` process remains the container HEALTHCHECK target until a +# follow-up moves health onto the Studio IDE door. Product `/chat` is Theorem +# register on the console (not this proxy). See SPEC-THEOREM-CHAT-REGISTER-1.0. HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \ CMD curl -fsS "http://127.0.0.1:${OPENWORK_PORT}/health" || exit 1