fix: restore model activity reasoning stream

This commit is contained in:
User
2026-07-14 15:03:07 +02:00
parent 1b1554b353
commit c7474f3852
11 changed files with 490 additions and 14 deletions
+3
View File
@@ -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
+2 -5
View File
@@ -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,
};
+59
View File
@@ -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: {
+14
View File
@@ -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);
@@ -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.
@@ -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.
+1
View File
@@ -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 };
+15 -7
View File
@@ -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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
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(<ModelActivityPanel onClose={vi.fn()} />);
+2 -2
View File
@@ -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,}/)
+10
View File
@@ -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([]);
});
+9
View File
@@ -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<SessionState>((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.