diff --git a/backend/src/bridge/session-bridge.ts b/backend/src/bridge/session-bridge.ts index c7f418f5..830cade4 100644 --- a/backend/src/bridge/session-bridge.ts +++ b/backend/src/bridge/session-bridge.ts @@ -3,6 +3,7 @@ import type { RpcClient } from "../rpc/rpc-client.js"; export type ClientEvent = | { type: "ui_request"; ui_request: any } | { type: "text_delta"; text: string } + | { type: "activity_delta"; text: string } | { type: "info"; [k: string]: any } | { type: "system_event"; [k: string]: any }; @@ -27,6 +28,8 @@ export class SessionBridge { this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" }); } else if (m.type === "message_update" && m.assistantMessageEvent?.type === "text_delta") { this.fan({ type: "text_delta", text: m.assistantMessageEvent.delta ?? "" }); + } else if (m.type === "message_update" && m.assistantMessageEvent?.type === "thinking_delta") { + this.fan({ type: "activity_delta", text: m.assistantMessageEvent.delta ?? "" }); } else if (m.type === "text_delta") { this.fan({ type: "text_delta", text: m.text ?? "" }); // tool_execution_* events are intentionally NOT forwarded: they clutter diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 0a6fe55c..138272e9 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -69,8 +69,7 @@ export function sessionRoutes( const options = { provider: s.provider, model: s.model, - // Keep the saved preference in the manifest; F1 starts tool-first. - thinking: "off", + thinking: s.thinking, author: getUser(req).id, question: b.question, }; @@ -117,9 +116,7 @@ export function sessionRoutes( const options = { provider: saved?.provider, model: saved?.model, - // Phase 1 must reach a widget instead of exposing a long reasoning trace. - // The session keeps its saved preference for later turns. - thinking: "off", + thinking: saved?.thinking ?? settings.thinking, author: getUser(req).id, mode: "resume" as const, }; diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index b1a89e2c..4d4f8dab 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -47,6 +47,65 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a unlinkSync(modelKey); }); +test("POST /sessions configura Pi con il thinking globale selezionato", async () => { + let configured: any; + const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} }; + const runtime = { bridge } as any; + const mgr = { + createFor: () => runtime, + configure: async (_rt: any, options: any) => { configured = options; }, + start: () => {}, + teardown: () => {}, + } as any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr, + thtRunner: { + ollamaEnsure: async () => ({ ok: true }), + searchPack: async () => {}, + sessionNew: async () => ({ id: "s-thinking" }), + } as any, + getSettings: () => ({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }) as any, + }); + + await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(configured.thinking).toBe("high"); +}); + +test("POST /sessions/:id/resume configura Pi con il thinking persistito", async () => { + let configured: any; + const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} }; + const runtime = { bridge } as any; + const mgr = { + get: () => undefined, + createFor: () => runtime, + configure: async (_rt: any, options: any) => { configured = options; }, + start: () => {}, + teardown: () => {}, + } as any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + mgr, + thtRunner: { + ollamaEnsure: async () => ({ ok: true }), + sessionShow: async () => ({ + status: "open", + archived: false, + provider: "zai", + model: "glm-5.2", + thinking: "medium", + }), + reopenSession: async () => {}, + } as any, + getSettings: () => ({ workspace: "psd", thinking: "low" }) as any, + }); + + await app.inject({ method: "POST", url: "/sessions/s-thinking/resume" }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(configured.thinking).toBe("medium"); +}); + test("POST /sessions/:id/response inoltra al bridge (no error)", async () => { const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { thtRunner: { diff --git a/backend/test/session-bridge.test.ts b/backend/test/session-bridge.test.ts index 8ae0dcb5..73d15d94 100644 --- a/backend/test/session-bridge.test.ts +++ b/backend/test/session-bridge.test.ts @@ -28,6 +28,20 @@ test("real Pi message_update (assistantMessageEvent text_delta) becomes a text_d expect(seen).toHaveLength(1); }); +test("real Pi thinking_delta becomes a dedicated activity_delta to the FE", () => { + const { rpc, fire } = fakeRpc(); + const b = new SessionBridge(rpc); + const seen: any[] = []; + b.onClientEvent((e) => seen.push(e)); + + fire({ + type: "message_update", + assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "Valuto le ambiguità" }, + }); + + expect(seen).toEqual([{ type: "activity_delta", text: "Valuto le ambiguità" }]); +}); + test("extension_ui_request nativo (method:input, title=json) diventa ui_request col descriptor ed è il pendente", () => { const { rpc, fire } = fakeRpc(); const b = new SessionBridge(rpc); diff --git a/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md b/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md new file mode 100644 index 00000000..53341a7b --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md @@ -0,0 +1,307 @@ +# Workflow UI Regressions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore Phase 1 model activity, make join review read-only, improve CTE presentation, and prevent malformed phase summaries from crashing the UI. + +**Architecture:** Preserve the existing Pi→backend→SSE→React pipeline while introducing one explicit `activity_delta` event and one explicit `join-review` widget contract. Enforce model-authored artifact shapes at the gate and retain defensive rendering for legacy payloads. + +**Tech Stack:** TypeScript, Fastify, React 18, Zustand, Vitest/Testing Library, Node test runner, Pi gate extension, Docker Compose. + +## Global Constraints + +- UI strings remain English; persisted workspace content remains in the workspace language. +- The harness remains the owner of workflow decisions and persistence. +- Joins are informational and are persisted as a complete set only after `Continue`. +- `Other — specify` is the only join-editing path and must not persist the rejected proposal. +- F3 rewrite approval remains automatic with no reviewer widget. +- No verbatim reasoning is persisted to session artifacts. + +--- + +### Task 1: Restore the Model Activity stream + +**Files:** +- Modify: `backend/test/routes-sessions.test.ts` +- Modify: `backend/test/session-bridge.test.ts` +- Modify: `backend/src/routes/sessions.ts` +- Modify: `backend/src/bridge/session-bridge.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/store/sessionStore.ts` +- Modify: `frontend/src/store/sessionStore.test.ts` +- Modify: `frontend/src/shell/ModelActivityPanel.tsx` +- Modify: `frontend/src/shell/ModelActivityPanel.test.tsx` + +**Interfaces:** +- Produces: `ClientEvent`/`StreamEvent` variant `{ type: "activity_delta"; text: string }`. +- Produces: Zustand `activity: Entry[]`, consumed by `ModelActivityPanel`. + +- [x] **Step 1: Write backend failing tests** + +Add route assertions showing create passes the configured `thinking` level and resume passes the +manifest level. Add a bridge test that emits: + +```ts +rpc.emit("event", { + type: "message_update", + assistantMessageEvent: { type: "thinking_delta", delta: "reasoning" }, +}); +expect(events).toContainEqual({ type: "activity_delta", text: "reasoning" }); +``` + +- [x] **Step 2: Run backend tests and verify RED** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts` + +Expected: failures show `thinking` is still `off` and no `activity_delta` is emitted. + +- [x] **Step 3: Implement backend event/config changes** + +Use `thinking: s.thinking` for new sessions and +`thinking: saved?.thinking ?? settings.thinking` on resume. Extend the bridge event union and map +nested `thinking_delta` to `activity_delta` without changing final `text_delta` behavior. + +- [x] **Step 4: Write frontend failing tests** + +Assert `applyEvent({ type: "activity_delta", text: "reasoning" })` appends to `activity` and not +`transcript`; render the activity panel after applying only `activity_delta` and expect the text. + +- [x] **Step 5: Run frontend tests and verify RED** + +Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts src/shell/ModelActivityPanel.test.tsx` + +Expected: TypeScript/test failures because the event variant and activity state do not exist. + +- [x] **Step 6: Implement frontend activity state** + +Add the event type, `activity` state, append logic mirroring streamed entry accumulation, reset it +with the session, and have the panel select `state.activity`. + +- [x] **Step 7: Run targeted tests and typechecks** + +Run: `cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts && npx tsc --noEmit -p .` + +Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts src/shell/ModelActivityPanel.test.tsx && npx tsc -b` + +Expected: PASS. + +### Task 2: Replace editable join selection with read-only review + +**Files:** +- Modify: `harness/.pi/extensions/gate/builders.js` +- Modify: `harness/.pi/extensions/gate/__tests__/builders.test.js` +- Modify: `harness/.pi/extensions/tht-gate.js` +- Create: `harness/.pi/extensions/gate/__tests__/gate_join_review.test.js` +- Modify: `harness/.pi/skills/tht-sessione/SKILL.md` +- Create: `frontend/src/widgets/JoinReviewWidget.tsx` +- Create: `frontend/src/widgets/JoinReviewWidget.test.tsx` +- Modify: `frontend/src/widgets/index.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/widgets/registry.test.tsx` + +**Interfaces:** +- Produces: `buildJoinReviewRequest({ id, phase, title, options })` with widget `join-review`. +- Consumes: options `{ id, label, detail?, rationale? }`. +- Produces: response `{ id, kind: "join-review", choices: allOptionIds }` on `Continue`. + +- [ ] **Step 1: Write failing builder and gate tests** + +Assert the builder emits read-only option details, `confirm_label: "Continue"`, and reserved controls. +Exercise `reviewer_decide` with only `join_modified` decisions; queue a Continue response containing +all ids and assert every `tht decision add` call occurs. Queue `control:"freetext"` and assert no +decision is written. + +- [ ] **Step 2: Run harness tests and verify RED** + +Run: `cd harness && node --test .pi/extensions/gate/__tests__/builders.test.js .pi/extensions/gate/__tests__/gate_join_review.test.js` + +Expected: builder/export/widget contract is missing and join calls still emit `multiselect`. + +- [ ] **Step 3: Implement builder and gate routing** + +Add `buildJoinReviewRequest`. In `reviewer_decide`, detect a non-empty, join-only merit list: + +```js +const joinOnly = opts.length > 0 && opts.every((o) => o.decision.type === "join_modified"); +``` + +Emit `join-review` with `detail` and `rationale`; after Continue persist all original decisions. +On free text, return feedback without persistence. Keep all other decisions on `multiselect`. + +- [ ] **Step 4: Write failing frontend widget tests** + +Render two join cards and assert there are no checkboxes. Click `Continue` and expect all ids in the +response. Open `Other — specify`, submit correction text, and expect a freetext control response. + +- [ ] **Step 5: Run frontend widget tests and verify RED** + +Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx` + +Expected: widget and registry entry are missing. + +- [ ] **Step 6: Implement and register JoinReviewWidget** + +Render semantic cards with label, detail, and rationale, one `Continue` primary button, and +`ReservedControls`. Register `join-review` and extend `WidgetOption` with optional `detail` and +`rationale` strings. + +- [ ] **Step 7: Update model instructions and verify targeted tests** + +Document that joins must be a separate join-only `reviewer_decide` call; the reviewer cannot remove +individual joins and textual corrections require a complete revised proposal. + +Run: `cd harness && npm test` + +Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx && npx tsc -b` + +Expected: PASS. + +### Task 3: Improve CTE plan layout and hide inert SQL controls + +**Files:** +- Modify: `frontend/src/viewers/CtePlanViewer.tsx` +- Modify: `frontend/src/viewers/CtePlanViewer.test.tsx` +- Modify: `frontend/src/viewers/SqlViewer.tsx` +- Modify: `frontend/src/viewers/SqlViewer.test.tsx` +- Modify: `frontend/src/viewers/CteResultViewer.test.tsx` + +**Interfaces:** +- `SqlViewer({ blocks })` shows the layout toggle only when `blocks.length > 1`. +- CTE plan data shape remains unchanged. + +- [ ] **Step 1: Write failing semantic/layout tests** + +Assert long filter data is rendered in distinct elements labeled Column, Operator, and Value; +assert card sections expose stable headings. Assert a one-block SQL viewer has no Horizontal or +Vertical controls while a two-block viewer retains both. + +- [ ] **Step 2: Run tests and verify RED** + +Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx` + +Expected: structured filter labels are absent and the one-block toggle is present. + +- [ ] **Step 3: Implement responsive CTE cards** + +Use a bordered header grid, prose blocks with `leading-relaxed`, metadata rows with fixed labels, +`break-words`/`font-mono` for physical identifiers, and a responsive filter grid such as +`grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]`. Keep output columns wrapping. + +- [ ] **Step 4: Hide the single-block toggle** + +Wrap the layout control in `{blocks.length > 1 && (...)}` without changing multi-block state or +rendering. + +- [ ] **Step 5: Run targeted tests and typecheck** + +Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx && npx tsc -b` + +Expected: PASS. + +### Task 4: Harden phase summaries against malformed open questions + +**Files:** +- Modify: `harness/.pi/extensions/gate/artifact-contracts.js` +- Modify: `harness/.pi/extensions/gate/__tests__/artifact-contracts.test.js` +- Modify: `harness/.pi/skills/tht-sessione/SKILL.md` +- Modify: `frontend/src/viewers/artifactV2.ts` +- Modify: `frontend/src/viewers/PhaseSummaryViewer.tsx` +- Modify: `frontend/src/viewers/PhaseSummaryViewer.test.tsx` +- Modify: `frontend/src/viewers/ArtifactView.test.tsx` + +**Interfaces:** +- Gate contract: `open_questions?: string[]`. +- Frontend legacy normalization: string entries pass through; objects prefer `question`, then + `label`; all other values become safe text or are omitted. + +- [ ] **Step 1: Write failing gate validation test** + +Pass the exact observed payload shape: + +```js +open_questions: [{ label: "pazienti_finale restituisce 0 righe", question: "Verificare i filtri" }] +``` + +Expect `ok:false` and an error naming `open_questions[0]`. + +- [ ] **Step 2: Run gate test and verify RED** + +Run: `cd harness && node --test .pi/extensions/gate/__tests__/artifact-contracts.test.js` + +Expected: payload is currently accepted. + +- [ ] **Step 3: Implement strict gate validation** + +Reject a non-array `open_questions` value and every non-string entry with an indexed error. +Document the exact array-of-strings shape in the session skill. + +- [ ] **Step 4: Write failing frontend resilience test** + +Render a phase-summary artifact containing the observed object and assert the screen displays +`Verificare i filtri` and does not show the ErrorBoundary fallback. + +- [ ] **Step 5: Run frontend test and verify RED** + +Run: `cd frontend && npx vitest run src/viewers/PhaseSummaryViewer.test.tsx src/viewers/ArtifactView.test.tsx` + +Expected: React reports an object child/rendering failure. + +- [ ] **Step 6: Implement safe legacy normalization** + +Add a small `phaseOpenQuestionText(value: unknown): string | null` helper and map/filter entries +before rendering. Preserve the strict public TypeScript contract for new v2 payloads. + +- [ ] **Step 7: Run targeted tests and typechecks** + +Run: `cd harness && npm test` + +Run: `cd frontend && npx vitest run src/viewers/PhaseSummaryViewer.test.tsx src/viewers/ArtifactView.test.tsx && npx tsc -b` + +Expected: PASS. + +### Task 5: Full verification, durable notes, and Docker deployment + +**Files:** +- Modify: `PROJECT_STATE.md` +- Modify or create under `brain/codebase/` and update `brain/index.md` if the vault is present. + +**Interfaces:** +- Running Compose services must use the newly built `thothii-core:local` and + `thothii-frontend:local` image ids. + +- [ ] **Step 1: Run all regression suites** + +Run: `cd backend && npx vitest run && npx tsc --noEmit -p . && npm run build` + +Run: `cd frontend && npx vitest run && npx tsc -b && npm run build` + +Run: `cd harness && npm test` + +Expected: all pass. + +- [ ] **Step 2: Update project state and durable architectural note** + +Record the implemented contracts, verification counts, deployment state, and the distinction +between building an image and recreating a service. + +- [ ] **Step 3: Build and recreate Compose services** + +Run: `docker compose build` + +Run: `docker compose up -d --force-recreate` + +Expected: both services are recreated from the new images. + +- [ ] **Step 4: Verify deployed containers** + +Run: `docker compose ps` + +Run: `docker inspect thothii-core thothii-frontend --format '{{.Name}} {{.Image}} {{.State.Health.Status}}'` + +Expected: both report the new image ids and `healthy`. + +- [ ] **Step 5: Review diff and commit intentionally** + +Run: `git diff --check && git status --short && git diff --stat` + +Commit only the files in this plan with a scoped message after review. diff --git a/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md b/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md new file mode 100644 index 00000000..1d011f0b --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md @@ -0,0 +1,68 @@ +# Workflow UI Regressions Design + +## Goal + +Restore visible model reasoning and make the F4/F6 review experience deterministic, +readable, and resilient to malformed model-authored artifacts. + +## Diagnosed causes + +- Session creation and resume force Pi `thinking` to `off`, regardless of the saved setting. +- The backend bridge forwards assistant `text_delta` events but drops `thinking_delta` events. +- The Model Activity panel reads final assistant text rather than a dedicated activity stream. +- F4 joins use the editable `multiselect` contract; `recommended` is not translated into + `selected` outside F2, so every join initially appears unchecked. +- The CTE result viewer passes one SQL block to a viewer that always displays its + multi-block Horizontal/Vertical control. +- CTE plan cards flatten long filters, table names, keys, and rationale into loosely spaced text. +- The F6 phase-summary payload accepted an object in `open_questions`; React then attempted to + render that object directly and the widget error boundary displayed the generic failure message. +- The F3 auto-approval fix was present in the image tag but not in the running containers because + they were built without being recreated. + +## Design + +### Model activity + +Session creation uses the global `thinking` preference and resume uses the persisted manifest +preference, falling back to the current global setting. `SessionBridge` maps Pi's nested +`thinking_delta` into a distinct SSE `activity_delta`. The frontend stores that stream separately +from final assistant text, and Model Activity renders only activity deltas. This prevents internal +reasoning from leaking into transcript-oriented state while making the panel accurately reflect +the configured model activity. + +### Join review + +F4 calls to `reviewer_decide` whose merit decisions are all `join_modified` become a read-only +`join-review` widget. Each proposed join is rendered as an informational card with its name, +join expression, and rationale. `Continue` returns every join id; the gate persists all decisions. +`Other — specify` returns textual feedback without persisting the current proposal, so the model +must revise and present the complete join set again. Mixed join/non-join calls remain regular +multiselects, and the skill instructs the model to keep joins in a separate call. + +### CTE presentation + +The plan viewer uses a responsive card layout: a compact numbered header, purpose and rationale +as readable prose, aligned metadata rows for dependencies and keys, wrapped table rows, and a +structured filter grid with separate column/operator/value fields. Output columns remain compact +wrapping chips. The SQL layout switch is hidden whenever the viewer receives fewer than two blocks. + +### Phase-summary resilience + +The gate validates that `open_questions` is an array of strings and reports a corrective error to +the model before emitting a widget. The frontend also normalizes legacy malformed entries to a +safe string (preferring `question`, then `label`) so old or externally produced payloads cannot +crash React. The session skill documents the exact `string[]` contract. + +### Deployment + +After targeted and full regression tests, build both Docker images and recreate the Compose +services. Verify that the running containers use the newly built image ids and that both health +checks pass. + +## Non-goals + +- Persisting verbatim model reasoning in session artifacts. +- Allowing individual joins to be removed from the join review. +- Redesigning multi-block SQL comparison behavior. +- Changing the eight-phase workflow or F3 auto-approval semantics. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 27ec7eea..aa1fac4c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -58,6 +58,7 @@ export interface UiResponse { export type StreamEvent = | { type: "ui_request"; ui_request: WidgetDescriptor } | { type: "text_delta"; text: string } + | { type: "activity_delta"; text: string } | { type: "info"; level?: "info" | "warning" | "error"; text: string } | { type: "system_event"; event: string; [k: string]: unknown }; diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 3c7f4f4b..637cd266 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -6,14 +6,22 @@ import { ModelActivityPanel } from "./ModelActivityPanel"; beforeEach(() => useSessionStore.getState().resetSession()); -test("renders the streamed model transcript", () => { - useSessionStore.getState().applyEvent({ type: "text_delta", text: "Promoting table dim_patient." }); +test("renders the streamed model activity", () => { + useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Promoting table dim_patient." }); render(); expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument(); }); +test("does not treat final assistant text as model reasoning", () => { + useSessionStore.getState().applyEvent({ type: "text_delta", text: "Final answer" }); + render(); + + expect(screen.queryByText("Final answer")).not.toBeInTheDocument(); + expect(screen.getByText("No activity yet.")).toBeInTheDocument(); +}); + test("renders markdown emphasis instead of literal asterisks", () => { - useSessionStore.getState().applyEvent({ type: "text_delta", text: "Chiarimento su **fibrillazione atriale**." }); + useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Chiarimento su **fibrillazione atriale**." }); render(); expect(screen.getByText("fibrillazione atriale").tagName).toBe("STRONG"); }); @@ -28,7 +36,7 @@ test("close button calls onClose", async () => { const EIGHT = ["para-01", "para-02", "para-03", "para-04", "para-05", "para-06", "para-07", "para-08"].join("\n\n"); test("collapsed shows only the last 5 paragraphs of the model-stream tail", () => { - useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT }); + useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT }); render(); expect(screen.getByText("para-08")).toBeInTheDocument(); expect(screen.getByText("para-04")).toBeInTheDocument(); @@ -37,7 +45,7 @@ test("collapsed shows only the last 5 paragraphs of the model-stream tail", () = }); test("expanding reveals the full stream", async () => { - useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT }); + useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT }); render(); await userEvent.click(screen.getByRole("button", { name: /show more/i })); expect(screen.getByText("para-01")).toBeInTheDocument(); @@ -46,7 +54,7 @@ test("expanding reveals the full stream", async () => { test("paragraphs stay separated as distinct blocks", () => { useSessionStore .getState() - .applyEvent({ type: "text_delta", text: "Primo paragrafo.\n\nSecondo paragrafo." }); + .applyEvent({ type: "activity_delta", text: "Primo paragrafo.\n\nSecondo paragrafo." }); render(); const first = screen.getByText("Primo paragrafo."); const second = screen.getByText("Secondo paragrafo."); @@ -58,7 +66,7 @@ test("paragraphs stay separated as distinct blocks", () => { test("separates activity updates concatenated after sentence punctuation", () => { useSessionStore.getState().applyEvent({ - type: "text_delta", + type: "activity_delta", text: "Ambiguità principale risolta.Finestra temporale risolta.Terza ambiguità risolta.", }); render(); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index c94da2c0..19d80fa1 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -24,13 +24,13 @@ export function formatModelActivity(text: string): string { * (refreshing as the stream grows); the expand toggle reveals the full stream. Opened * on demand from the rotating activity icon. */ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { - const transcript = useSessionStore((s) => s.transcript); + const activity = useSessionStore((s) => s.activity); const [expanded, setExpanded] = useState(false); // Blank lines are markdown's paragraph separator — split on them (not on every "\n", // which would also break single soft-wrapped lines apart) so each block below is a // real paragraph and the tail cut lands on paragraph boundaries. - const paragraphs = transcript + const paragraphs = activity .map((e) => e.text) .join("\n\n") .split(/\n{2,}/) diff --git a/frontend/src/store/sessionStore.test.ts b/frontend/src/store/sessionStore.test.ts index f50eb666..cdeefd0e 100644 --- a/frontend/src/store/sessionStore.test.ts +++ b/frontend/src/store/sessionStore.test.ts @@ -15,6 +15,15 @@ test("text_delta accumulates into transcript", () => { expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi"); }); +test("activity_delta accumulates separately from the assistant transcript", () => { + const s = useSessionStore.getState(); + s.applyEvent({ type: "activity_delta", text: "Valuto " }); + s.applyEvent({ type: "activity_delta", text: "le ambiguità" }); + + expect(useSessionStore.getState().activity.at(-1)?.text).toBe("Valuto le ambiguità"); + expect(useSessionStore.getState().transcript).toEqual([]); +}); + test("info appends to stepMessages", () => { useSessionStore.getState().applyEvent({ type: "info", level: "warning", text: "attenzione" }); expect(useSessionStore.getState().stepMessages.at(-1)).toEqual({ level: "warning", text: "attenzione" }); @@ -122,4 +131,5 @@ test("resetSession clears lastUserEntry and stepMessages", () => { st.resetSession(); expect(useSessionStore.getState().lastUserEntry).toBeNull(); expect(useSessionStore.getState().stepMessages).toEqual([]); + expect(useSessionStore.getState().activity).toEqual([]); }); diff --git a/frontend/src/store/sessionStore.ts b/frontend/src/store/sessionStore.ts index ebe23e7c..7fd66b90 100644 --- a/frontend/src/store/sessionStore.ts +++ b/frontend/src/store/sessionStore.ts @@ -9,6 +9,7 @@ interface Entry { interface SessionState { pendingWidget: WidgetDescriptor | null; transcript: Entry[]; + activity: Entry[]; toasts: { level: string; text: string }[]; stepMessages: { level: string; text: string }[]; lastUserEntry: { kind: "input" | "choice"; text: string } | null; @@ -31,6 +32,7 @@ interface SessionState { const empty = { pendingWidget: null, transcript: [] as Entry[], + activity: [] as Entry[], toasts: [] as { level: string; text: string }[], stepMessages: [] as { level: string; text: string }[], lastUserEntry: null as { kind: "input" | "choice"; text: string } | null, @@ -63,6 +65,13 @@ export const useSessionStore = create((set) => ({ // Streamed text means the turn is alive (also covers reattaching mid-turn). return { transcript: t, agentActive: true }; } + if (e.type === "activity_delta") { + const activity = [...st.activity]; + const last = activity.at(-1); + if (last) activity[activity.length - 1] = { role: "assistant", text: last.text + e.text }; + else activity.push({ role: "assistant", text: e.text }); + return { activity, agentActive: true }; + } if (e.type === "info") { const stepMessages = [...st.stepMessages, { level: e.level ?? "info", text: e.text }]; // An error during the active phase marks that phase red until the next gate.