fix: restore model activity reasoning stream
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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,}/)
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
Reference in New Issue
Block a user