feat(nomos): per-session turn serialization + chat reliability/UX fixes
The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.
Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
(continuation worker, idle sweep, answer-question, /resume, reconnect)
skip non-blocking when busy; the live chat path waits briefly then bails
cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
"continued" only after a real run (review P0) so a busy-skip can't lose a
finished-execution result. Idle nudge bumps only after delivery (P1).
Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
per drop; a terminal task.status event clears stuck streaming/disconnected
state and dismisses the connection toast. Reconnect no longer spawns turns.
Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
tool card (auto-opened, tail-pinned) -- not just the per-window rail.
Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).
VERSION: 0.14.2 -> 0.15.0
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import type { ChatMessage } from '$lib/stores/chat'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import type { SessionQuestion } from '$lib/api'
|
||||
|
||||
let {
|
||||
@@ -83,8 +84,15 @@
|
||||
const indicatorLabel = $derived.by(() => {
|
||||
if (error) return error
|
||||
if (!streaming && indicatorDone) return 'Done'
|
||||
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
|
||||
if (running) return running.description
|
||||
// Prefer the running PLAN STEP as the headline — it's stable across the
|
||||
// step's many tool calls, so the line stops rewriting itself on every
|
||||
// command (the "thinking overwrites itself" complaint, F6). Falls back to
|
||||
// the current tool only when there's no active step (a plan-less Q&A or
|
||||
// between steps), and to a plain "thinking…" otherwise.
|
||||
const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
|
||||
if (runningStep) return runningStep.description
|
||||
const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
|
||||
if (runningTool) return runningTool.description
|
||||
return 'Agent is thinking…'
|
||||
})
|
||||
|
||||
@@ -195,6 +203,27 @@
|
||||
if (streaming) return
|
||||
onSend(q)
|
||||
}
|
||||
|
||||
// Merge live streaming output (from the activity log's `run` entry) onto the
|
||||
// in-flight turn's tool calls so the inline tool card shows command output as
|
||||
// it arrives — the place the operator naturally "checks the tool". Only the
|
||||
// last assistant message can be streaming, so only it gets enriched; history
|
||||
// is untouched (and has no live output anyway). (F4)
|
||||
function toolsWithLive(
|
||||
tools: ToolCallResult[],
|
||||
entries: ActivityEntry[],
|
||||
isLiveTurn: boolean
|
||||
): ToolCallResult[] {
|
||||
if (!isLiveTurn) return tools
|
||||
const liveById = new Map<string, string>()
|
||||
for (const e of entries) {
|
||||
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
|
||||
}
|
||||
if (liveById.size === 0) return tools
|
||||
return tools.map((t) =>
|
||||
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
|
||||
@@ -274,7 +303,7 @@
|
||||
the text below it. -->
|
||||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||
<AgentTrace
|
||||
tools={msg.tools}
|
||||
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
|
||||
status={traceStatus}
|
||||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||
/>
|
||||
@@ -306,9 +335,10 @@
|
||||
<div
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
|
||||
>
|
||||
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-warning" aria-hidden="true" />
|
||||
<span class="text-warning-foreground flex-1"
|
||||
>Agent connection lost. The task may still be running.</span
|
||||
>Connection dropped — the task is still running and will catch up here automatically.
|
||||
Reconnect to refresh now.</span
|
||||
>
|
||||
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
|
||||
>Reconnect</Button
|
||||
|
||||
@@ -37,30 +37,21 @@
|
||||
const sessionActivityLog = activityLogFor(sessionId)
|
||||
// Started here (rather than left to TaskContextPanel's own onMount) so the
|
||||
// workspace is already tracking touched entities/plan/questions before the
|
||||
// context rail ever mounts — it needs that live even while the rail stays
|
||||
// hidden (see hasContext below).
|
||||
// context rail mounts.
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const workspace = workspaceFor(sessionId)
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const touchedEntities = workspace.touched
|
||||
// eslint-disable-next-line svelte/valid-compile
|
||||
const openQuestion = workspace.openQuestion
|
||||
let loading = $state(true)
|
||||
|
||||
// The context rail (Scope/Activity) is only worth its screen space once
|
||||
// there's something in it — a brand-new task otherwise opens to an empty
|
||||
// "entities appear here" placeholder next to an equally empty activity
|
||||
// list. Show it the moment either has real content, and keep it shown
|
||||
// from then on (no flicker back to hidden if e.g. touched entities later
|
||||
// expire). An open question does NOT gate this anymore — it renders
|
||||
// inline in the chat thread itself (see ChatThread's `question` prop
|
||||
// below), not in this rail.
|
||||
let hasContext = $state(false)
|
||||
$effect(() => {
|
||||
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
|
||||
hasContext = true
|
||||
}
|
||||
})
|
||||
// F7 (plan 2026-08-03): the rail used to mount on demand (hasContext gate),
|
||||
// which DESTROYED and remounted the ChatThread — losing the input draft and
|
||||
// scroll position — and reflowed the chat column the moment the first
|
||||
// activity/touched entity landed ("layout looks off when a chat goes from
|
||||
// empty to content"). The layout is now stable from the moment the window
|
||||
// opens: one Splitpanes, one ChatThread, the rail always present showing
|
||||
// its own empty state ("Waiting for activity…") until there's something to
|
||||
// show. A stable-but-initially-quiet rail is a better trade than a jumping
|
||||
// layout.
|
||||
|
||||
// startSessionWorkspace's cleanup is registered via onDestroy below rather
|
||||
// than returned from this callback — onMount ignores a returned function
|
||||
@@ -93,7 +84,7 @@
|
||||
<p class="text-sm text-muted-foreground">Task not found.</p>
|
||||
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
|
||||
</div>
|
||||
{:else if hasContext}
|
||||
{:else}
|
||||
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
|
||||
<Pane>
|
||||
<ChatThread
|
||||
@@ -115,20 +106,5 @@
|
||||
<TaskContextPanel {sessionId} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<ChatThread
|
||||
messages={$chatMessages}
|
||||
streaming={$chatStreaming}
|
||||
connectionState={$chatConnectionState}
|
||||
error={$chatError}
|
||||
chatErrors={$chatErrors}
|
||||
activityLog={sessionActivityLog}
|
||||
{sessionId}
|
||||
question={$openQuestion}
|
||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||
onCancel={() => cancelSessionStream(sessionId)}
|
||||
onReconnect={() => loadSessionChat(sessionId)}
|
||||
onDismissError={dismissError}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
let { tool }: { tool: ToolCallResult } = $props()
|
||||
let expanded = $state(false)
|
||||
let liveEl = $state<HTMLPreElement | null>(null)
|
||||
|
||||
const status = $derived.by(() => {
|
||||
if (tool.type === 'tool_use') return 'running'
|
||||
@@ -16,6 +17,15 @@
|
||||
return 'done'
|
||||
})
|
||||
|
||||
// Auto-open while a command is streaming its output, so the operator sees it
|
||||
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
|
||||
// lands (status flips off running) liveOutput clears and the card respects
|
||||
// the manual toggle again. (F4)
|
||||
const open = $derived(expanded || !!tool.liveOutput)
|
||||
$effect(() => {
|
||||
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
||||
})
|
||||
|
||||
const label = $derived(toolActivityLabel(tool))
|
||||
|
||||
const argsSummary = $derived.by(() => {
|
||||
@@ -36,8 +46,8 @@
|
||||
<button
|
||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
disabled={!hasDetail}
|
||||
aria-expanded={open}
|
||||
disabled={!hasDetail && !tool.liveOutput}
|
||||
>
|
||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
||||
{#if status === 'running'}
|
||||
@@ -57,17 +67,30 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
||||
{#if hasDetail}
|
||||
{#if hasDetail || tool.liveOutput}
|
||||
<ChevronRight
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
{#if open}
|
||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
||||
{#if tool.liveOutput}
|
||||
<div>
|
||||
<div
|
||||
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
|
||||
>
|
||||
<Loader2 class="size-2.5 animate-spin" />
|
||||
Live output
|
||||
</div>
|
||||
<pre
|
||||
bind:this={liveEl}
|
||||
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tool.args}
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
||||
import { openEntityWindow } from '$lib/stores/windows'
|
||||
|
||||
// Merged plan + activity timeline, designed for the narrow rail:
|
||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
||||
@@ -423,10 +425,23 @@
|
||||
>
|
||||
{tool.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
{#if !tool.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if tool.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(tool.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if tOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
@@ -516,10 +531,23 @@
|
||||
>
|
||||
{e.description}
|
||||
</span>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
{#if !e.link}
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{#if e.link}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
||||
onclick={() => openEntityWindow(e.link!.slug)}
|
||||
>
|
||||
<ExternalLinkIcon class="size-3" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if eOpen}
|
||||
<div
|
||||
transition:slide={{ duration: 120 }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { writable } from 'svelte/store'
|
||||
// real store graph (workspace.ts, e.g., starts a top-level setInterval).
|
||||
vi.mock('./chat', () => ({
|
||||
messages: writable([]),
|
||||
currentSession: writable(null),
|
||||
chatFor: vi.fn(() => ({
|
||||
messages: writable([]),
|
||||
streaming: writable(false),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { derived, type Readable } from 'svelte/store'
|
||||
import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
|
||||
import { messages, chatFor, currentSession, type ChatMessage, type ToolCallResult } from './chat'
|
||||
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
|
||||
import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream'
|
||||
import type { PlanStep, Session } from '$lib/api'
|
||||
@@ -33,6 +33,11 @@ export interface ActivityEntry {
|
||||
// Distinct from `detail`, which is only populated once the tool_result
|
||||
// arrives — for an auto-run that is the moment the command finishes.
|
||||
liveOutput?: string
|
||||
// Deep link to an artifact this entry references — a recorded knowledge doc
|
||||
// or a looked-up entity — so the operator can open it directly instead of
|
||||
// having to navigate there by hand. Rendered as a clickable chip in the
|
||||
// timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…").
|
||||
link?: { kind: 'knowledge' | 'entity'; slug: string }
|
||||
}
|
||||
|
||||
// Detail text is kept full-length (not hard-truncated to a preview snippet)
|
||||
@@ -55,6 +60,32 @@ function stringifyResult(result: unknown): string {
|
||||
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
|
||||
}
|
||||
|
||||
// A knowledge doc slug as printed in upsert_knowledge's result text — mirrors
|
||||
// cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding").
|
||||
const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/
|
||||
// An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID
|
||||
// or free text doesn't, so we only deep-link when it does.
|
||||
const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/
|
||||
|
||||
// entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's
|
||||
// args so its activity entry can link straight to that entity's window (F5).
|
||||
function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined {
|
||||
if (!args || typeof args !== 'object') return undefined
|
||||
const slug = (args as Record<string, unknown>)?.slug_or_id
|
||||
if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) {
|
||||
return { kind: 'entity', slug }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// knowledgeLinkFromResult extracts the created doc's slug from an
|
||||
// upsert_knowledge result so the "Recorded: …" entry links to the doc (F5).
|
||||
function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined {
|
||||
const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
|
||||
const m = s.match(KNOWLEDGE_SLUG_RE)
|
||||
return m ? { kind: 'knowledge', slug: m[0] } : undefined
|
||||
}
|
||||
|
||||
// Pure derivation, parameterized so it can back both the global "current
|
||||
// session" activityLog below and a per-session activityLogFor(sessionId) for
|
||||
// a floating task window.
|
||||
@@ -160,6 +191,9 @@ export function computeActivityLog(
|
||||
running.type = 'tool_done'
|
||||
running.status = 'done'
|
||||
running.detail = stringifyResult(t.result)
|
||||
if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') {
|
||||
running.link = entityLinkFromArgs(t.args)
|
||||
}
|
||||
} else {
|
||||
// Historical/persisted tool calls arrive as one merged record (args
|
||||
// + result on the same object, see mergeToolCalls in chat.ts) rather
|
||||
@@ -177,7 +211,11 @@ export function computeActivityLog(
|
||||
toolName: t.name,
|
||||
stepSeq: stepTag,
|
||||
indent: stepTag != null,
|
||||
status: t.error ? 'failed' : 'done'
|
||||
status: t.error ? 'failed' : 'done',
|
||||
link:
|
||||
t.name === 'get_entity' || t.name === 'get_entity_knowledge'
|
||||
? entityLinkFromArgs(t.args)
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -196,7 +234,8 @@ export function computeActivityLog(
|
||||
type: 'knowledge',
|
||||
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
|
||||
timestamp: freeze(kid, msgTs),
|
||||
status: 'done'
|
||||
status: 'done',
|
||||
link: knowledgeLinkFromResult(t.result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -233,8 +272,28 @@ export function computeActivityLog(
|
||||
// re-derivation can't march it forward. Owned here, outside the derivation,
|
||||
// so it survives re-runs. The per-session path has its own Map keyed by id.
|
||||
const frozenTimestamps = new Map<string, number>()
|
||||
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
|
||||
computeActivityLog($msgs, $steps, $task, frozenTimestamps)
|
||||
|
||||
// Live execution output for whichever session the global "current session"
|
||||
// view is on — used to attach streaming `run` output to the global activityLog
|
||||
// (the per-window activityLogFor has its own). Follows currentSession via a
|
||||
// derived setup function so the subscription moves to the right session's
|
||||
// store when the operator switches tasks.
|
||||
const currentLiveOutput = derived(
|
||||
currentSession,
|
||||
($sid, set) => {
|
||||
if (!$sid) {
|
||||
set(null)
|
||||
return
|
||||
}
|
||||
return liveExecutionOutputFor($sid).subscribe(set)
|
||||
},
|
||||
null as LiveExecutionOutput | null
|
||||
)
|
||||
|
||||
export const activityLog = derived(
|
||||
[messages, planSteps, currentTask, currentLiveOutput],
|
||||
([$msgs, $steps, $task, $live]) =>
|
||||
withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live)
|
||||
)
|
||||
|
||||
// Attach streaming output to the `run` entry that is currently executing.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '$lib/api'
|
||||
import type { ChatEvent, Session, Message } from '$lib/api'
|
||||
import type { ToolCallResult } from '$lib/types'
|
||||
import { liveEvents, subscribeEvents } from './events'
|
||||
|
||||
export type { ToolCallResult }
|
||||
|
||||
@@ -78,14 +79,90 @@ export const currentSession = writable<string | null>(null)
|
||||
export const sessions = writable<Session[]>([])
|
||||
export const sessionMessages = writable<Message[]>([])
|
||||
export const error = writable<string | null>(null)
|
||||
export const chatErrors = writable<{ id: string; message: string; action?: string }[]>([])
|
||||
export const chatErrors = writable<{ id: string; message: string; action?: string; tag?: string }[]>([])
|
||||
|
||||
export function dismissError(id: string) {
|
||||
chatErrors.update((e) => e.filter((x) => x.id !== id))
|
||||
}
|
||||
|
||||
export function addChatError(message: string, action?: string) {
|
||||
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
|
||||
export function addChatError(message: string, action?: string, tag?: string) {
|
||||
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action, tag }])
|
||||
}
|
||||
|
||||
// Session statuses where no turn is running — the agent reached a terminal
|
||||
// state (done/failed/abandoned) or paused for operator input (awaiting_input).
|
||||
// A task.status event landing in one of these is an authoritative "the turn
|
||||
// ended" signal, used by clearTurnState (F3) to unstick a chat view that lost
|
||||
// its SSE stream mid-turn.
|
||||
const TURN_ENDED_STATUS = new Set(['done', 'failed', 'abandoned', 'awaiting_input'])
|
||||
|
||||
// humanizeChatError turns raw transport/SDK error strings into operator-
|
||||
// readable, non-alarming copy. The raw forms ("llm: error in input stream:
|
||||
// …", "Failed to fetch", "HTTP 502") read as catastrophic and unactionable;
|
||||
// most are transient model-connection drops where the task itself is fine.
|
||||
// Used for both the inline error box (LLM error events) and the connection
|
||||
// toast (F2).
|
||||
function humanizeChatError(raw: string): string {
|
||||
const s = raw.toLowerCase()
|
||||
if (
|
||||
s.includes('input stream') ||
|
||||
s.includes('llm:') ||
|
||||
s.includes('failed to fetch') ||
|
||||
s.includes('network') ||
|
||||
s.includes('econnreset') ||
|
||||
s.includes('timeout') ||
|
||||
/http 5\d\d/.test(s)
|
||||
) {
|
||||
return 'The model connection dropped. The task keeps running in the background — it will catch up here automatically.'
|
||||
}
|
||||
if (/http 401|http 403|unauthor|forbidden/.test(s)) {
|
||||
return 'Your session expired. Reconnect to continue.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// clearTurnState resets a session's chat view to a clean "connected, idle"
|
||||
// state — the recovery action when a dropped SSE left it stuck showing
|
||||
// streaming/disconnected after the turn had already ended. Clears the window
|
||||
// bundle, the global bundle (if that's the viewed session), and any
|
||||
// connection-lost toasts tagged 'connection' (F2/F3).
|
||||
function clearTurnState(sessionId: string) {
|
||||
const win = sessionChats.get(sessionId)
|
||||
if (win) {
|
||||
win.streaming.set(false)
|
||||
win.connectionState.set('connected')
|
||||
}
|
||||
if (get(currentSession) === sessionId) {
|
||||
streaming.set(false)
|
||||
connectionState.set('connected')
|
||||
}
|
||||
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
|
||||
}
|
||||
|
||||
// One app-lifetime subscription to the always-on event stream: a terminal
|
||||
// task.status for a session we have open is the authoritative end-of-turn
|
||||
// signal, and recovers a chat view whose SSE dropped without a 'done' event
|
||||
// (the "task never ended" symptom). Ref-counted by subscribeEvents, so this
|
||||
// shares the single connection the rest of the app already keeps open.
|
||||
//
|
||||
// Lazily armed from chatFor() (P2.2) rather than at module import, so
|
||||
// importing this module — e.g. in a test — doesn't open an SSE connection as
|
||||
// an import side-effect.
|
||||
let chatEventSyncArmed = false
|
||||
function ensureChatEventSync() {
|
||||
if (chatEventSyncArmed) return
|
||||
chatEventSyncArmed = true
|
||||
subscribeEvents()
|
||||
liveEvents.subscribe((events) => {
|
||||
const ev = events[0]
|
||||
if (!ev || ev.type !== 'task.status') return
|
||||
const sid = ev.correlation_id
|
||||
if (!sid) return
|
||||
const status = (ev.data as { status?: string } | null)?.status
|
||||
if (typeof status === 'string' && TURN_ENDED_STATUS.has(status)) {
|
||||
clearTurnState(sid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Per-session controller tracking. Multiple tasks can stream concurrently
|
||||
@@ -378,80 +455,35 @@ export function sendMessage(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleDisconnect is called when the SSE stream drops mid-turn without
|
||||
// receiving a 'done' event. Falls back to polling and attempts reconnection.
|
||||
// handleDisconnect is called when the global SSE stream drops mid-turn
|
||||
// without a 'done' event. NOTE: the global single-session chat action path
|
||||
// (sendMessage → this) is not currently wired to any UI — only the
|
||||
// per-session window path (sendSessionMessage/startTask) is live, which has
|
||||
// its own inline equivalent. This is kept safe and turn-free in case the
|
||||
// global path is re-wired: it falls back to polling and surfaces one
|
||||
// connection toast; recovery is driven by the poller + the terminal
|
||||
// task.status subscription (clearTurnState), NEVER by POSTing an empty
|
||||
// message that would spawn a duplicate background turn (F1/F2).
|
||||
function handleDisconnect(sessionId: string) {
|
||||
const MAX_RECONNECT = 3
|
||||
connectionState.set('disconnected')
|
||||
startPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
|
||||
let attempts = 0
|
||||
let delay = 1000
|
||||
|
||||
const attemptReconnect = () => {
|
||||
if (get(currentSession) !== sessionId || attempts >= MAX_RECONNECT) {
|
||||
connectionState.set('disconnected')
|
||||
streaming.set(false)
|
||||
return
|
||||
}
|
||||
if (attempts > 0) {
|
||||
connectionState.set('reconnecting')
|
||||
addChatError(`Reconnecting to agent (attempt ${attempts + 1}/${MAX_RECONNECT})…`, 'Dismiss')
|
||||
}
|
||||
attempts++
|
||||
const controller = streamChat(
|
||||
'',
|
||||
sessionId,
|
||||
(_ev: ChatEvent) => {},
|
||||
(_err: string) => {
|
||||
delay = Math.min(delay * 2, 8000)
|
||||
setTimeout(attemptReconnect, delay)
|
||||
},
|
||||
() => {
|
||||
if (get(currentSession) === sessionId) {
|
||||
connectionState.set('connected')
|
||||
streaming.set(false)
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sessionId)) {
|
||||
activeControllers.get(sessionId)?.abort()
|
||||
}
|
||||
activeControllers.set(sessionId, controller)
|
||||
}
|
||||
|
||||
setTimeout(attemptReconnect, delay)
|
||||
addChatError(
|
||||
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
|
||||
'Dismiss',
|
||||
'connection'
|
||||
)
|
||||
}
|
||||
|
||||
// Manual reconnect for the global view (currently unused — windows use
|
||||
// loadSessionChat via their onReconnect). Re-fetches the transcript and
|
||||
// resets state; does NOT start a new turn.
|
||||
export function reconnect() {
|
||||
const sid = get(currentSession)
|
||||
if (!sid) return
|
||||
connectionState.set('reconnecting')
|
||||
const controller = streamChat(
|
||||
'',
|
||||
sid,
|
||||
(_ev: ChatEvent) => {},
|
||||
(_err: string) => {
|
||||
connectionState.set('disconnected')
|
||||
addChatError(
|
||||
'Reconnect failed. The task may still be running — try sending a message to wake the agent.',
|
||||
'Dismiss'
|
||||
)
|
||||
},
|
||||
() => {
|
||||
if (get(currentSession) === sid) {
|
||||
connectionState.set('connected')
|
||||
streaming.set(false)
|
||||
loadSessionMessages(sid)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sid)) {
|
||||
activeControllers.get(sid)?.abort()
|
||||
}
|
||||
activeControllers.set(sid, controller)
|
||||
streaming.set(false)
|
||||
connectionState.set('connected')
|
||||
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
|
||||
loadSessionMessages(sid)
|
||||
}
|
||||
|
||||
export function newChat() {
|
||||
@@ -524,6 +556,7 @@ const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
// Lazily creates (and memoizes) the store bundle for a session — call this to
|
||||
// get the stores to subscribe to; it does not fetch anything.
|
||||
export function chatFor(sessionId: string): SessionChatState {
|
||||
ensureChatEventSync() // arm the terminal task.status → clearTurnState recovery (P2.2)
|
||||
let c = sessionChats.get(sessionId)
|
||||
if (!c) {
|
||||
c = {
|
||||
@@ -549,6 +582,16 @@ function startSessionPolling(sessionId: string) {
|
||||
const msgs = await fetchMessages(sessionId)
|
||||
if (get(chat.streaming)) return // re-check: the fetch itself takes time
|
||||
chat.messages.set(toChatMessages(msgs))
|
||||
// F3 safety net: if we're recovering from a dropped SSE but the
|
||||
// session's task has already reached a turn-ended status, clear the
|
||||
// stuck disconnected/streaming flags. Catches the edge where the
|
||||
// terminal task.status event fired during the brief disconnect window.
|
||||
if (get(chat.connectionState) !== 'connected') {
|
||||
const s = get(sessions).find((x) => x.id === sessionId)
|
||||
if (s?.status && TURN_ENDED_STATUS.has(s.status)) {
|
||||
clearTurnState(sessionId)
|
||||
}
|
||||
}
|
||||
}, 3000)
|
||||
)
|
||||
}
|
||||
@@ -566,7 +609,15 @@ export function stopSessionPolling(sessionId: string) {
|
||||
// equivalent of loadSessionMessages, for a window rather than the main view.
|
||||
export async function loadSessionChat(sessionId: string): Promise<void> {
|
||||
const chat = chatFor(sessionId)
|
||||
// A fresh (re)load is a clean view: not streaming, connected, no stale
|
||||
// error. This also serves the window's manual "Reconnect" button —
|
||||
// re-fetching the transcript and resetting state, never spawning a new
|
||||
// turn (the old reconnect path POSTed an empty message that started a
|
||||
// duplicate background turn; F1/F2 removed that).
|
||||
chat.streaming.set(false)
|
||||
chat.connectionState.set('connected')
|
||||
chat.error.set(null)
|
||||
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
|
||||
const msgs = await fetchMessagesOrNotFound(sessionId)
|
||||
if (msgs === null) {
|
||||
chat.notFound.set(true)
|
||||
@@ -668,7 +719,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
} else if (ev.type === 'error') {
|
||||
chat.error.set(ev.data)
|
||||
chat.error.set(humanizeChatError(ev.data))
|
||||
}
|
||||
},
|
||||
(err: string) => {
|
||||
@@ -676,12 +727,21 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
||||
chat.streaming.set(false)
|
||||
return
|
||||
}
|
||||
chat.error.set(err)
|
||||
// Network drop (no 'done' received): show ONE connection-lost surface
|
||||
// and recover via the poller + terminal task.status event (F2/F3).
|
||||
// Don't also set chat.error — the banner+toast convey it, and a raw
|
||||
// "Failed to fetch" alongside would just be noise.
|
||||
if (!receivedDone) {
|
||||
chat.connectionState.set('disconnected')
|
||||
startSessionPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
addChatError(
|
||||
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
|
||||
'Dismiss',
|
||||
'connection'
|
||||
)
|
||||
} else {
|
||||
// Stream ended cleanly but fetch reported an error tail — surface it.
|
||||
chat.error.set(humanizeChatError(err))
|
||||
chat.streaming.set(false)
|
||||
}
|
||||
},
|
||||
@@ -793,7 +853,7 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
})
|
||||
startSessionPolling(sessionId)
|
||||
} else if (ev.type === 'error') {
|
||||
c.error.set(ev.data)
|
||||
c.error.set(humanizeChatError(ev.data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,12 +883,16 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
||||
chat.streaming.set(false)
|
||||
return
|
||||
}
|
||||
chat.error.set(err)
|
||||
if (!receivedDone && sessionId) {
|
||||
chat.connectionState.set('disconnected')
|
||||
startSessionPolling(sessionId)
|
||||
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
|
||||
addChatError(
|
||||
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
|
||||
'Dismiss',
|
||||
'connection'
|
||||
)
|
||||
} else {
|
||||
chat.error.set(humanizeChatError(err))
|
||||
chat.streaming.set(false)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,6 +57,11 @@ export interface ToolCallResult {
|
||||
args?: Record<string, unknown>
|
||||
result?: unknown
|
||||
error?: string
|
||||
// Streaming command output for an in-flight `run` call — attached live from
|
||||
// the execution.output event stream (execstream.ts) while the call is still
|
||||
// running, so the tool card can show output as it arrives instead of all at
|
||||
// once when the tool_result lands. Not present on persisted/historical calls.
|
||||
liveOutput?: string
|
||||
}
|
||||
|
||||
// ---- Message content (persisted messages from /agent/sessions/:id) ----
|
||||
|
||||
Reference in New Issue
Block a user