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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user