diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 3ebb15de..faf7a68a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -57,10 +57,37 @@ export interface UiResponse { tables?: { id: string; enacted: boolean; columns?: string[] }[]; } +export type ActivityKind = + | "prompt" + | "thinking" + | "assistant" + | "tool" + | "gate" + | "status" + | "lifecycle"; + +export interface ActivityEntry { + kind: ActivityKind; + phase: string | null; + text: string; + level?: "info" | "warning" | "error"; + toolCallId?: string; + status?: "running" | "completed" | "failed"; +} + export type StreamEvent = | { type: "ui_request"; ui_request: WidgetDescriptor } | { type: "text_delta"; text: string } | { type: "activity_delta"; text: string } + | { + type: "activity_event"; + activity: { + kind: "tool"; + toolCallId: string; + toolName: string; + status: "running" | "completed" | "failed"; + }; + } | { type: "info"; level?: "info" | "warning" | "error"; text: string } | { type: "system_event"; event: string; [k: string]: unknown }; diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 19d80fa1..b9b60afc 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -24,7 +24,8 @@ 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 activity = useSessionStore((s) => s.activity); + const activityLog = useSessionStore((s) => s.activityLog); + const activity = activityLog.filter((entry) => entry.kind === "thinking"); const [expanded, setExpanded] = useState(false); // Blank lines are markdown's paragraph separator — split on them (not on every "\n", diff --git a/frontend/src/store/sessionStore.test.ts b/frontend/src/store/sessionStore.test.ts index cdeefd0e..4f659644 100644 --- a/frontend/src/store/sessionStore.test.ts +++ b/frontend/src/store/sessionStore.test.ts @@ -15,18 +15,111 @@ test("text_delta accumulates into transcript", () => { expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi"); }); +test("folds prompt, lifecycle, streams, tool updates, and gates in chronological order", () => { + const store = useSessionStore.getState(); + store.setPhase("F1"); + store.setLastUserEntry({ kind: "input", text: "How many patients?" }); + store.applyEvent({ type: "system_event", event: "agent_start" }); + store.applyEvent({ type: "activity_delta", text: "Inspect " }); + store.applyEvent({ type: "activity_delta", text: "schema" }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "t1", toolName: "bash", status: "running" }, + }); + store.applyEvent({ type: "text_delta", text: "I found " }); + store.applyEvent({ type: "text_delta", text: "the candidates." }); + store.applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "t1", toolName: "bash", status: "completed" }, + }); + store.applyEvent({ + type: "ui_request", + ui_request: { id: "g1", widget: "select", phase: "F1_understanding", title: "Confirm intent" }, + }); + + const log = useSessionStore.getState().activityLog; + expect(log.map((entry) => entry.kind)).toEqual([ + "prompt", + "lifecycle", + "thinking", + "tool", + "assistant", + "gate", + ]); + expect(log.map((entry) => entry.phase)).toEqual(["F1", "F1", "F1", "F1", "F1", "F1"]); + expect(log.find((entry) => entry.kind === "thinking")?.text).toBe("Inspect schema"); + expect(log.find((entry) => entry.kind === "assistant")?.text).toBe("I found the candidates."); + expect(log[3]).toEqual({ + kind: "tool", + phase: "F1", + text: "bash", + toolCallId: "t1", + status: "completed", + }); + expect(log.at(-1)?.text).toBe("Confirm intent"); +}); + +test("stream chunks coalesce only across adjacent rows of the same kind", () => { + const store = useSessionStore.getState(); + store.applyEvent({ type: "activity_delta", text: "before" }); + store.applyEvent({ type: "info", level: "info", text: "checkpoint" }); + store.applyEvent({ type: "activity_delta", text: "after" }); + + expect(useSessionStore.getState().activityLog.map((entry) => [entry.kind, entry.text])).toEqual([ + ["thinking", "before"], + ["status", "checkpoint"], + ["thinking", "after"], + ]); +}); + 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().activityLog.at(-1)).toMatchObject({ + kind: "thinking", + text: "Valuto le ambiguità", + }); expect(useSessionStore.getState().transcript).toEqual([]); }); -test("info appends to stepMessages", () => { +test("info appends to stepMessages and the chronological status log", () => { useSessionStore.getState().applyEvent({ type: "info", level: "warning", text: "attenzione" }); expect(useSessionStore.getState().stepMessages.at(-1)).toEqual({ level: "warning", text: "attenzione" }); + expect(useSessionStore.getState().activityLog.at(-1)).toEqual({ + kind: "status", + phase: null, + text: "attenzione", + level: "warning", + }); +}); + +test("a terminal tool event without a start appends a correlated terminal row", () => { + useSessionStore.getState().applyEvent({ + type: "activity_event", + activity: { kind: "tool", toolCallId: "t2", toolName: "read", status: "failed" }, + }); + + expect(useSessionStore.getState().activityLog).toEqual([ + { + kind: "tool", + phase: null, + text: "read", + toolCallId: "t2", + status: "failed", + }, + ]); +}); + +test("recordLifecycle appends a local lifecycle row using the current phase", () => { + const store = useSessionStore.getState(); + store.setPhase("F4"); + store.recordLifecycle("Resuming session"); + + expect(useSessionStore.getState().activityLog).toEqual([ + { kind: "lifecycle", phase: "F4", text: "Resuming session" }, + ]); }); test("clearPending removes the widget", () => { @@ -124,12 +217,12 @@ test("setLastUserEntry records the entry and clears stepMessages", () => { expect(useSessionStore.getState().stepMessages).toEqual([]); }); -test("resetSession clears lastUserEntry and stepMessages", () => { +test("resetSession clears lastUserEntry, stepMessages, and the complete activity log", () => { const st = useSessionStore.getState(); st.setLastUserEntry({ kind: "choice", text: "promote" }); st.applyEvent({ type: "info", level: "info", text: "y" }); st.resetSession(); expect(useSessionStore.getState().lastUserEntry).toBeNull(); expect(useSessionStore.getState().stepMessages).toEqual([]); - expect(useSessionStore.getState().activity).toEqual([]); + expect(useSessionStore.getState().activityLog).toEqual([]); }); diff --git a/frontend/src/store/sessionStore.ts b/frontend/src/store/sessionStore.ts index 7fd66b90..9573ce1e 100644 --- a/frontend/src/store/sessionStore.ts +++ b/frontend/src/store/sessionStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { StreamEvent, WidgetDescriptor } from "../api/types"; +import type { ActivityEntry, StreamEvent, WidgetDescriptor } from "../api/types"; interface Entry { role: "assistant"; @@ -9,7 +9,7 @@ interface Entry { interface SessionState { pendingWidget: WidgetDescriptor | null; transcript: Entry[]; - activity: Entry[]; + activityLog: ActivityEntry[]; toasts: { level: string; text: string }[]; stepMessages: { level: string; text: string }[]; lastUserEntry: { kind: "input" | "choice"; text: string } | null; @@ -26,13 +26,36 @@ interface SessionState { setPhase: (phase: string | null) => void; pushToast: (toast: { level: string; text: string }) => void; setLastUserEntry: (e: { kind: "input" | "choice"; text: string }) => void; + recordLifecycle: (text: string) => void; setAgentActive: (v: boolean) => void; } +function phaseOf(value: unknown, fallback: string | null): string | null { + return typeof value === "string" && value ? value.split("_")[0] : fallback; +} + +function appendStream( + log: ActivityEntry[], + entry: ActivityEntry & { kind: "thinking" | "assistant" }, +): ActivityEntry[] { + const next = [...log]; + const last = next.at(-1); + if (last?.kind === entry.kind) next[next.length - 1] = { ...last, text: last.text + entry.text }; + else next.push(entry); + return next; +} + +function lifecycleText(event: string): string { + if (event === "agent_start") return "Agent started"; + if (event === "agent_end") return "Agent finished"; + const words = event.replaceAll("_", " ").trim(); + return words ? words[0].toUpperCase() + words.slice(1) : "System event"; +} + const empty = { pendingWidget: null, transcript: [] as Entry[], - activity: [] as Entry[], + activityLog: [] as ActivityEntry[], toasts: [] as { level: string; text: string }[], stepMessages: [] as { level: string; text: string }[], lastUserEntry: null as { kind: "input" | "choice"; text: string } | null, @@ -46,41 +69,103 @@ export const useSessionStore = create((set) => ({ ...empty, applyEvent: (e) => set((st) => { - if (e.type === "ui_request") + if (e.type === "ui_request") { + const currentPhase = phaseOf(e.ui_request.phase, st.currentPhase); return { pendingWidget: e.ui_request, // The turn is blocked on the gate, not ended: keep the agent marked active. agentActive: true, - currentPhase: e.ui_request.phase - ? e.ui_request.phase.split("_")[0] - : st.currentPhase, + currentPhase, // A new gate means the phase moved on (or re-presented): clear any error flag. phaseError: null, + activityLog: [ + ...st.activityLog, + { + kind: "gate" as const, + phase: currentPhase, + text: e.ui_request.title ?? "Review requested", + }, + ], }; + } if (e.type === "text_delta") { const t = [...st.transcript]; const last = t.at(-1); if (last && !st.pendingWidget) t[t.length - 1] = { role: "assistant", text: last.text + e.text }; else t.push({ role: "assistant", text: e.text }); // Streamed text means the turn is alive (also covers reattaching mid-turn). - return { transcript: t, agentActive: true }; + return { + transcript: t, + activityLog: appendStream(st.activityLog, { + kind: "assistant", + phase: st.currentPhase, + text: e.text, + }), + 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 }; + return { + activityLog: appendStream(st.activityLog, { + kind: "thinking", + phase: st.currentPhase, + text: e.text, + }), + agentActive: true, + }; + } + if (e.type === "activity_event") { + const entry: ActivityEntry = { + kind: "tool", + phase: st.currentPhase, + text: e.activity.toolName, + toolCallId: e.activity.toolCallId, + status: e.activity.status, + }; + if (e.activity.status === "running") { + return { activityLog: [...st.activityLog, entry], agentActive: true }; + } + + const activityLog = [...st.activityLog]; + let matchingIndex = -1; + for (let index = activityLog.length - 1; index >= 0; index -= 1) { + if (activityLog[index].kind === "tool" && activityLog[index].toolCallId === e.activity.toolCallId) { + matchingIndex = index; + break; + } + } + if (matchingIndex >= 0) { + activityLog[matchingIndex] = { ...activityLog[matchingIndex], status: e.activity.status }; + } else { + activityLog.push(entry); + } + return { activityLog, agentActive: true }; } if (e.type === "info") { - const stepMessages = [...st.stepMessages, { level: e.level ?? "info", text: e.text }]; + const level = e.level ?? "info"; + const stepMessages = [...st.stepMessages, { level, text: e.text }]; + const activityLog = [ + ...st.activityLog, + { kind: "status" as const, phase: st.currentPhase, text: e.text, level }, + ]; // An error during the active phase marks that phase red until the next gate. - return e.level === "error" ? { stepMessages, phaseError: st.currentPhase } : { stepMessages }; + return e.level === "error" + ? { stepMessages, activityLog, phaseError: st.currentPhase } + : { stepMessages, activityLog }; } - if (e.type === "system_event") + if (e.type === "system_event") { + const activityLog = [ + ...st.activityLog, + { + kind: "lifecycle" as const, + phase: st.currentPhase, + text: lifecycleText(e.event), + }, + ]; return e.event === "agent_end" - ? { lastSystemEvent: e, agentActive: false } - : { lastSystemEvent: e }; + ? { lastSystemEvent: e, activityLog, agentActive: false } + : { lastSystemEvent: e, activityLog }; + } return {}; }), clearPending: () => set({ pendingWidget: null }), @@ -88,6 +173,22 @@ export const useSessionStore = create((set) => ({ setPhase: (phase) => set({ currentPhase: phase }), pushToast: (toast) => set((st) => ({ toasts: [...st.toasts, toast] })), // A user entry (question, gate choice, steer) hands the ball back to the harness. - setLastUserEntry: (e) => set({ lastUserEntry: e, stepMessages: [], agentActive: true }), + setLastUserEntry: (e) => + set((st) => ({ + lastUserEntry: e, + stepMessages: [], + activityLog: [ + ...st.activityLog, + { kind: "prompt", phase: st.currentPhase, text: e.text }, + ], + agentActive: true, + })), + recordLifecycle: (text) => + set((st) => ({ + activityLog: [ + ...st.activityLog, + { kind: "lifecycle", phase: st.currentPhase, text }, + ], + })), setAgentActive: (v) => set({ agentActive: v }), })); diff --git a/frontend/src/stream/useSessionStream.test.tsx b/frontend/src/stream/useSessionStream.test.tsx index ce4d4988..5fab088b 100644 --- a/frontend/src/stream/useSessionStream.test.tsx +++ b/frontend/src/stream/useSessionStream.test.tsx @@ -22,7 +22,7 @@ test("opens an EventSource and feeds NAMED events to the store", () => { expect(useSessionStore.getState().pendingWidget?.id).toBe("u1"); }); -test("feeds named activity_delta events to the model activity store", () => { +test("feeds named activity_delta events to the thinking activity timeline", () => { renderHook(() => useSessionStream("s1")); const es = FakeEventSource.instances[0]; @@ -30,7 +30,30 @@ test("feeds named activity_delta events to the model activity store", () => { es.emitNamed("activity_delta", { type: "activity_delta", text: "Analizzo le ambiguità" }) ); - expect(useSessionStore.getState().activity.at(-1)?.text).toBe("Analizzo le ambiguità"); + expect(useSessionStore.getState().activityLog.at(-1)).toMatchObject({ + kind: "thinking", + text: "Analizzo le ambiguità", + }); +}); + +test("feeds named activity_event events to the tool activity timeline", () => { + renderHook(() => useSessionStream("s1")); + const es = FakeEventSource.instances[0]; + + act(() => + es.emitNamed("activity_event", { + type: "activity_event", + activity: { kind: "tool", toolCallId: "t1", toolName: "bash", status: "running" }, + }) + ); + + expect(useSessionStore.getState().activityLog).toContainEqual({ + kind: "tool", + phase: null, + text: "bash", + toolCallId: "t1", + status: "running", + }); }); test("also feeds UNNAMED (default message) events to the store", () => { diff --git a/frontend/src/stream/useSessionStream.ts b/frontend/src/stream/useSessionStream.ts index 75f88e1d..a54ca0fd 100644 --- a/frontend/src/stream/useSessionStream.ts +++ b/frontend/src/stream/useSessionStream.ts @@ -30,6 +30,7 @@ export function useSessionStream(sessionId: string | null, generation = 0) { "ui_request", "text_delta", "activity_delta", + "activity_event", "info", "system_event", ] as const;