mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 04:45:12 +00:00
* feat(server): foundation for OpenClaw agent file-output attribution
Phase 1 of TKT-762 — surface files OpenClaw agents produce as
artifacts inline in chat + a per-agent Outputs rail. This commit
lays the storage + I/O foundation only; turn-lifecycle wiring,
HTTP routes, and UI follow in subsequent phases.
- New `produced_files` Drizzle table (FK→agent_definitions with
cascade, unique on (agent, path) so re-modifications upsert).
Migration 0002_chemical_whirlwind.sql. Adapter-agnostic schema
— V1 only enables the watcher for openclaw, V2 can plug Claude
/ Codex into the same table without migrating.
- `ProducedFilesStore` — snapshot/finalize-turn diff API plus
by-turn / by-agent queries and a path-resolver that enforces
workspace-root containment for the download / preview routes.
- `walkWorkspace` — bounded recursive workspace walker; skips
symlinks (no host-fs smuggling), excludes node_modules / .git /
.cache, hard-capped at 50k entries / depth 16.
- `file-preview` helper — extension + magic-byte MIME detection,
bounded text-snippet reader (1 MB cap), inline image base64
reader (4 MB cap). Streaming download path lives in the route
layer (next phase) — this module only handles the small
in-memory reads the preview UX needs.
* feat(server): attribute openclaw turn outputs to the harness layer
Phase 2 of TKT-762 — wire the per-turn workspace diff into the
single dispatch path that owns every turn's lifecycle. Two prior
wiring points the original plan named (the OpenClaw HTTP chat
route + OutboundQueueService.tryDispatch) were collapsed in dev
into agent-harness-service.runDetachedTurn — both direct sends
and queued sends route through it now, so a single hook covers
both. The old `OutboundQueueService` is gone; its successor
`message-queue.ts` re-enters runDetachedTurn for the queued
case, so we still only need to bracket once.
Changes:
- New `produced_files` variant on `AgentStreamEvent` so the
inline artifact card has a wire-format hook independent of the
REST API.
- `ProducedFilesStore` gains `resolveAgentDefinitionId` to bridge
gateway-side openclaw agent names to the harness's
`agent_definitions.id`, handling both the reconciled-row shape
(id == openclaw name) and the BrowserOS-created shape
(id = oc-<uuid>, name = openclaw display name).
- `AgentHarnessService.runDetachedTurn`: snapshot the openclaw
workspace before `runtime.send(...)`, finalize the diff in the
outer finally, push the resulting rows as a `produced_files`
event. Adapter-gated to openclaw only — Claude / Codex agents
write to the user's own filesystem and don't need
attribution.
- Skip attribution on user-cancel (`abort.signal.aborted`) so
the side effects of an aborted turn don't get surfaced as
"outputs you asked for." On runtime errors we still attribute,
because partial outputs are what the user is most likely to
want to recover.
- Lazy-init the store via `tryGetProducedFilesStore()` so tests
that swap in a fake `agentStore` don't trip the
process-wide `getDb()` initialisation guard.
- File attribution extracted into `attributeTurnFiles` helper to
keep `runDetachedTurn`'s cognitive complexity under the lint
ceiling.
Verifications:
- Server tsgo --noEmit clean for changed files.
- 162/162 server-api tests pass.
- Biome lint clean on all three changed files.
* feat(server): expose produced-files HTTP API for /agents
Phase 3 of TKT-762 — surface the rows Phase 2 attributes via four
read-only endpoints under the existing `/agents` router. Mounted
where the agents page already polls so the rail UI doesn't add
a second router/origin to its trust boundary.
Routes:
- GET /agents/:agentId/files
Outputs-rail data, grouped by the assistant turn that
produced each batch, newest first. `?limit=` clamps to N
rows server-side (default 200).
- GET /agents/:agentId/files/turn/:turnId
Per-turn refresh — used by the inline-card consumer to
rebuild metadata after the SSE `produced_files` event lands,
and by direct fetches that missed the live event.
- GET /agents/files/:fileId/preview
Discriminated `FilePreview` JSON: text snippet (≤1MB),
base64 image (≤4MB), pdf metadata, or `binary` placeholder
when neither preview path applies. 404 when the file id is
unknown OR the on-disk file disappeared after attribution.
- GET /agents/files/:fileId/download
Streams raw bytes via `Bun.file().stream()` with
`Content-Disposition: attachment` and the detected MIME
type. The fileId is opaque — the server resolves the agent
and on-disk path; the client never sees a path, so traversal
is impossible by construction.
Service layer:
- `AgentHarnessService` gains `listAgentFiles`,
`listAgentFilesForTurn`, `previewProducedFile`, and
`resolveProducedFileForDownload`. All four are no-ops for
claude / codex adapters (they return null/[]) so the route
contract stays uniform across adapters even though only
openclaw produces rows in v1.
- New `ProducedFileEntry` and `ProducedFilesRailGroup` DTOs —
trimmed wire shapes that strip `agentDefinitionId` and
`sessionKey` from the on-disk row.
Verifications:
- Server tsgo --noEmit clean for changed files (only pre-
existing `Bun` global warning).
- 162/162 server-api tests pass.
- Biome clean on both changed files.
Smoke-test instructions for the route shape live in the plan
under §6 and §8; full end-to-end smoke happens in Phase 6.
* feat(agent): client-side hooks + types for agent file outputs
Phase 4 of TKT-762 — frontend foundation for the inline artifact
card and the per-agent Outputs rail. UI components themselves
land in Phase 5; this commit only adds types, hooks, and shared
helpers so the wiring is in place when the components arrive.
New module: `apps/agent/lib/agent-files/`
- `types.ts` — `ProducedFile`, `ProducedFilesRailGroup`, and the
discriminated `FilePreview` union, mirrored from the server-side
DTOs in `apps/server/src/api/services/agents/agent-harness-service.ts`.
The `agentDefinitionId` / `sessionKey` columns on the on-disk
rows deliberately do NOT exist at the type boundary — clients
refer to files by opaque `id`.
- `file-helpers.ts` — pure helpers: `inferFileKind` (icon
routing), `formatFileSize`, `extensionOf`, `basenameOf`,
`buildFileDownloadUrl`. No React, no fetch, no DOM — anything
stateful belongs in the hooks.
- `useAgentOutputs.ts` — `useAgentOutputs(agentId)` for the rail,
`useAgentTurnFiles(agentId, turnId)` for the inline card,
`useInvalidateAgentOutputs()` for the chat-stream-completion
hook (Phase 5 will plumb this), and `useRefreshAgentOutputs()`
for the rail's manual refresh button.
- `useFilePreview.ts` — `useFilePreview(fileId)` with
`staleTime: Infinity` (previews are immutable for a given id;
no point refetching on focus). Always opt-in (`enabled`) — the
preview only loads when the user clicks a row.
- `index.ts` — barrel re-export so consumers import from one path.
Touched in `apps/agent/entrypoints/app/agents/`:
- `agent-harness-types.ts` — added `produced_files` variant + the
`HarnessProducedFile` type to `AgentHarnessStreamEvent`. Mirrors
the server-side change from Phase 2 so the client SSE consumer
type-narrows correctly.
- `useAgents.ts` — exported the previously-private `agentsFetch`
helper and the `AGENT_QUERY_KEYS` registry so the agent-files
hooks reuse them without duplicating fetch / key conventions.
Three new keys added: `agentOutputs`, `agentTurnFiles`,
`filePreview`.
Verifications:
- Agent tsgo --noEmit clean.
- Biome clean on all touched files.
* feat(agent): inline artifact card + per-agent outputs rail
Wires the chat surface to the produced-files API shipped earlier:
- Inline artifact card under each assistant turn that produced files,
populated by the live `produced_files` SSE event (resumes also stamp
`turnId` so a missed live event can fall back to the per-turn fetch).
- Collapsible right-side Outputs rail on the agent conversation page,
grouped by turn, with Refresh + per-agent open/close persistence in
localStorage. Gated to openclaw adapters in v1.
- Shared file preview Sheet branches on the FilePreview union: text
snippet (markdown for `.md`/`.mdx`, otherwise pre+code), image data
URL, and download-only fallback for pdf/binary/missing.
- Conversation hook invalidates the rail's React Query cache from its
finally block so newly attributed files appear without a manual
refresh.
* feat(agent-files): polish — symlink-safe paths + toast on failures
- `resolveFilePath` now rejects symlink-escapes from the workspace
by realpath-resolving both endpoints and re-checking containment.
Lexical traversal (`..` segments) still fails fast without
touching the filesystem.
- Added `produced-files-store.test.ts` with 6 path-resolution cases
including a symlink whose target lives outside the workspace
root — the prior string-only check would have allowed this.
- File preview Sheet: surfaces preview-load failures in a toast
(in addition to the inline error block, which is easy to miss
when the body has scrolled). Download button now intercepts the
click so a missing baseUrl shows a toast instead of silently
hiding the button.
- Outputs rail: refresh failures fire `toast.error` with the
underlying message.
* fix(agent-files): drop duplicate `/agents` prefix from client paths
`agentsFetch` / `buildAgentApiUrl` already prepend `/agents`, but
the file-output hooks were passing fully-qualified paths
(`/agents/<id>/files`, `/agents/files/<id>/preview`, etc.) which
resolved to `/agents/agents/...` and 404'd. Fixed the four call
sites to pass paths relative to the `/agents` root.
* fix(agents): strip openclaw role envelope from chat history
PR #924 introduced a second `<role>…</role>` prefix for openclaw
turns — a single-line block distinct from the multi-line BrowserOS
role TKT-774 wired the unwrap against. Because TKT-774's
`stripOuterRoleEnvelope` matched the BrowserOS prefix exactly, the
openclaw envelope sailed through unstripped and user messages on
openclaw agents rendered the full preamble in /sessions/main/history
responses.
Make the strip adapter-agnostic: any
`<role …>…</role>\n\n<user_request>\n…\n</user_request>` shape gets
unwrapped. Drops the now-unused BROWSEROS_ACP_AGENT_INSTRUCTIONS
constant and adds a regression test that uses the openclaw form
verbatim.
* feat(agent-files): inline file-card strip with rail deep-link
Replaces Phase 5's row-list ArtifactCard with a horizontal strip
of small file cards under any assistant turn that produced files.
Click a card → opens the FilePreviewSheet directly (preview +
download). Click View / +N → opens the per-agent Outputs rail and
scrolls / expands the matching turn group.
The card strip:
- Caps at 4 visible cards; remainder collapses into a +N pill that
shares the View handler.
- Owns its own FilePreviewSheet instance (parallel to the
deprecated ArtifactCard) so the per-card preview path doesn't
fight with the rail's Sheet.
- Hidden during streaming and absent when producedFiles is empty.
- Adapter-gated upstream: AgentCommandConversation only passes the
open-rail callback when adapter==='openclaw', so claude / codex
agents render no rail-opening affordance.
Rail changes:
- Accepts focusTurnId + onFocusTurnConsumed; the matching
RailTurnGroup expands and scrollIntoView's on focus, then fires
the consumed callback so the parent can drop the URL state.
- ?outputsTurn=<turnId> deep-links work: external nav opens the
rail, sets focusTurnId, and clears the param after consumption.
ArtifactCard is marked @deprecated; remove in a follow-up once
nothing imports it.
* fix(agent-files): keep file-card strip visible after history reload
After Phase 7 the inline FileCardStrip vanished as soon as a turn
finished: `filterTurnsPersistedInHistory` dropped the optimistic
turn once history reloaded, and history items don't carry
`producedFiles`. So the user could see a file produced inside an
assistant message but no card to open it.
Two fixes in tandem so the strip survives both the just-finished
case AND a fresh page load:
- New `selectStripOnlyTurns` keeps persisted turns that still
carry `producedFiles`. `ConversationMessage` learns a
`stripOnly` mode that renders only the trailing strip (no
duplicate user/assistant bubbles, since those are rendered by
`ClawChatMessage`).
- `AgentCommandConversation` now also calls `useAgentOutputs` and
passes `tailStripGroups` to `ClawChat`. Each rail group not
already covered by a live or strip-only turn renders as its own
tail `FileCardStrip` after history. Dedup keys on `turnId` so
the same turn never doubles up.
Adapter-gated upstream — claude / codex agents skip the
useAgentOutputs fetch entirely. The card click still opens the
preview Sheet directly; View / +N still deep-link to the rail at
the matching turn group.
* fix(agent-files): per-turn association + cache invalidation
Two fixes for the inline file-card strip:
1. Strips were stacking at the conversation tail because every
produced-files group rendered as a tail strip after history.
New `mapHistoryToProducedFilesGroups` matches each group to
the assistant history message that came from its turn — by
`group.turnPrompt` vs the first non-blank line of the
preceding user message — and ClawChat renders the strip
directly under that bubble. Groups that don't match any
history pair (orphans) still fall through to the tail.
2. `useInvalidateAgentOutputs` was passing `undefined` as the
baseUrl placeholder to `invalidateQueries({ queryKey })` —
react-query's positional partial-match doesn't treat
undefined as a wildcard, so the cache stayed stale until the
query refetched on its own (e.g. window focus). Switched to
predicate-based invalidation that matches by [agentOutputs
marker, agentId] regardless of baseUrl. Same for the per-turn
files key.
Net effect: send a turn that produces files → strip appears
under the just-finished assistant message; reload the page →
strips still appear under the right bubbles, not bunched at
the bottom.
* fix(agent-files): review feedback — name guard, RFC 5987, limit cap
Three review-flagged issues:
1. Path traversal via agent display name — `getHostWorkspaceDir`
accepted any string and `path.join`'d it, so a name like
`../../tmp` escaped `.openclaw`. The pre-turn snapshot would
then walk that escaped directory and attribute every file to
the new turn; resolveSafeWorkspacePath's containment check is
relative to the same escaped root so it would later serve
arbitrary host paths. Added `isAgentWorkspaceNameSafe` (rejects
`..`, separators, control chars, leading dots, empty); the
builder now throws on unsafe names plus a defensive
realpath-style containment check after the join. Harness
wraps the call so the path-traversal trip just disables file
attribution for the turn instead of failing the whole send.
Six-case regression test pinned.
2. `encodeRfc6266Filename` JSDoc claimed an RFC 5987
`filename*=UTF-8''<percent-encoded>` fallback but the impl
only stripped CRLFs/quotes. Now actually emits the fallback
when non-ASCII is present; helper returns the full
`filename="…"; filename*=UTF-8''…` attribute pair so the call
site doesn't have to wrap in quotes.
3. `/agents/:agentId/files` `?limit=` was forwarded to the DB
uncapped — extracted `parseAgentFilesLimit` that clamps to
[1, 500] before forwarding.
Also extracted `resolveSafeWorkspaceDir` + `snapshotWorkspaceForTurn`
helpers off `runDetachedTurn` so the new safety branch doesn't
push it past biome's cognitive-complexity cap.
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
type AgentHarnessStreamEvent,
|
|
attachToHarnessTurn,
|
|
cancelHarnessTurn,
|
|
chatWithHarnessAgent,
|
|
fetchActiveHarnessTurn,
|
|
} from '@/entrypoints/app/agents/useAgents'
|
|
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
|
import type {
|
|
AgentConversationTurn,
|
|
AssistantPart,
|
|
ConversationTurnFile,
|
|
ToolEntry,
|
|
UserAttachmentPreview,
|
|
} from '@/lib/agent-conversations/types'
|
|
import { useInvalidateAgentOutputs } from '@/lib/agent-files'
|
|
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
|
import { consumeSSEStream } from '@/lib/sse'
|
|
import { buildToolLabel } from '@/lib/tool-labels'
|
|
import { mapAgentHarnessToolStatus } from './agent-stream-events'
|
|
|
|
export interface SendInput {
|
|
text: string
|
|
attachments?: ServerAttachmentPayload[]
|
|
// Optional preview metadata used to render the optimistic user turn.
|
|
// Built by the composer at staging time; the server only sees the
|
|
// payload array.
|
|
attachmentPreviews?: UserAttachmentPreview[]
|
|
}
|
|
|
|
interface UseAgentConversationOptions {
|
|
// The hook always speaks to the harness chat path now; the OpenClaw
|
|
// legacy /claw/agents/:id/chat surface was removed in Step 12. The
|
|
// option remains for forward-compatibility.
|
|
runtime?: 'agent-harness'
|
|
sessionKey?: string | null
|
|
history?: OpenClawChatHistoryMessage[]
|
|
onComplete?: () => void
|
|
onSessionKeyChange?: (sessionKey: string) => void
|
|
/**
|
|
* Server-side active turn id, surfaced via the listing query. When
|
|
* this changes from null/<id> to a different non-null id while we
|
|
* aren't already streaming (e.g. the server just popped a queued
|
|
* message and started a new turn), the hook reattaches via
|
|
* /chat/active so the chat panel picks up the live stream without
|
|
* waiting for a remount.
|
|
*/
|
|
activeTurnId?: string | null
|
|
}
|
|
|
|
export function useAgentConversation(
|
|
agentId: string,
|
|
options: UseAgentConversationOptions = {},
|
|
) {
|
|
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
|
const [streaming, setStreaming] = useState(false)
|
|
const invalidateAgentOutputs = useInvalidateAgentOutputs()
|
|
// Stable ref so the resume effect doesn't re-subscribe on every
|
|
// render (the hook's returned callable is freshly closured each
|
|
// time, but the underlying queryClient is stable).
|
|
const invalidateAgentOutputsRef = useRef(invalidateAgentOutputs)
|
|
invalidateAgentOutputsRef.current = invalidateAgentOutputs
|
|
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
|
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
|
const textAccRef = useRef('')
|
|
const thinkAccRef = useRef('')
|
|
const streamAbortRef = useRef<AbortController | null>(null)
|
|
const onCompleteRef = useRef(options.onComplete)
|
|
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
|
|
// Per-turn resume bookkeeping. `turnId` is captured from the response
|
|
// header; `lastSeq` advances with every SSE event so a reconnect can
|
|
// resume via Last-Event-ID.
|
|
const turnIdRef = useRef<string | null>(null)
|
|
const lastSeqRef = useRef<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
sessionKeyRef.current = options.sessionKey ?? ''
|
|
}, [options.sessionKey])
|
|
|
|
useEffect(() => {
|
|
historyRef.current = options.history ?? []
|
|
}, [options.history])
|
|
|
|
useEffect(() => {
|
|
onCompleteRef.current = options.onComplete
|
|
}, [options.onComplete])
|
|
|
|
useEffect(() => {
|
|
onSessionKeyChangeRef.current = options.onSessionKeyChange
|
|
}, [options.onSessionKeyChange])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
streamAbortRef.current?.abort()
|
|
}
|
|
}, [])
|
|
|
|
// Indirection for the resume effect below: lets it call the latest
|
|
// event handler without re-subscribing on every render.
|
|
const processEventRef = useRef<(event: AgentHarnessStreamEvent) => void>(
|
|
() => {},
|
|
)
|
|
|
|
const updateCurrentTurnParts = (
|
|
updater: (parts: AssistantPart[]) => AssistantPart[],
|
|
) => {
|
|
setTurns((prev) => {
|
|
const last = prev[prev.length - 1]
|
|
if (!last) return prev
|
|
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
|
})
|
|
}
|
|
|
|
const appendTextDelta = (delta: string) => {
|
|
textAccRef.current += delta
|
|
const text = textAccRef.current
|
|
updateCurrentTurnParts((parts) => {
|
|
const last = parts[parts.length - 1]
|
|
if (last?.kind === 'text') {
|
|
return [...parts.slice(0, -1), { ...last, text }]
|
|
}
|
|
return [...parts, { kind: 'text', text }]
|
|
})
|
|
}
|
|
|
|
const appendThinkingDelta = (delta: string) => {
|
|
thinkAccRef.current += delta
|
|
const text = thinkAccRef.current
|
|
updateCurrentTurnParts((parts) => {
|
|
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
|
if (idx >= 0) {
|
|
return [
|
|
...parts.slice(0, idx),
|
|
{ ...parts[idx], text, done: false },
|
|
...parts.slice(idx + 1),
|
|
]
|
|
}
|
|
return [...parts, { kind: 'thinking', text, done: false }]
|
|
})
|
|
}
|
|
|
|
const appendErrorText = (message: string) => {
|
|
updateCurrentTurnParts((parts) => [
|
|
...parts,
|
|
{ kind: 'text', text: `Error: ${message}` },
|
|
])
|
|
}
|
|
|
|
const markCurrentTurnDone = () => {
|
|
updateCurrentTurnParts((parts) =>
|
|
parts.map((part) =>
|
|
part.kind === 'thinking' ? { ...part, done: true } : part,
|
|
),
|
|
)
|
|
setTurns((prev) => {
|
|
const last = prev[prev.length - 1]
|
|
if (!last) return prev
|
|
return [...prev.slice(0, -1), { ...last, done: true }]
|
|
})
|
|
}
|
|
|
|
const setProducedFilesOnCurrentTurn = (files: ConversationTurnFile[]) => {
|
|
setTurns((prev) => {
|
|
const last = prev[prev.length - 1]
|
|
if (!last) return prev
|
|
// Replace, don't merge: the server's diff is authoritative for
|
|
// the just-completed turn — duplicate events shouldn't grow the
|
|
// list, and a re-attribution should overwrite an earlier one.
|
|
return [...prev.slice(0, -1), { ...last, producedFiles: files }]
|
|
})
|
|
}
|
|
|
|
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
|
if (event.type !== 'tool_call') return
|
|
const rawName = event.title || event.rawType || 'tool call'
|
|
const { label, subject } = buildToolLabel(
|
|
rawName,
|
|
event.text ? { description: event.text } : undefined,
|
|
)
|
|
const tool: ToolEntry = {
|
|
id: event.id ?? crypto.randomUUID(),
|
|
name: rawName,
|
|
label,
|
|
subject,
|
|
status: mapAgentHarnessToolStatus(event.status),
|
|
}
|
|
|
|
updateCurrentTurnParts((parts) => {
|
|
for (let i = parts.length - 1; i >= 0; i--) {
|
|
const part = parts[i]
|
|
if (
|
|
part.kind === 'tool-batch' &&
|
|
part.tools.some((existing) => existing.id === tool.id)
|
|
) {
|
|
const tools = part.tools.map((existing) =>
|
|
existing.id === tool.id ? { ...existing, ...tool } : existing,
|
|
)
|
|
return [
|
|
...parts.slice(0, i),
|
|
{ ...part, tools },
|
|
...parts.slice(i + 1),
|
|
]
|
|
}
|
|
}
|
|
|
|
const last = parts[parts.length - 1]
|
|
if (last?.kind === 'tool-batch') {
|
|
return [
|
|
...parts.slice(0, -1),
|
|
{ ...last, tools: [...last.tools, tool] },
|
|
]
|
|
}
|
|
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
|
})
|
|
}
|
|
|
|
const processAgentHarnessStreamEvent = (event: AgentHarnessStreamEvent) => {
|
|
switch (event.type) {
|
|
case 'text_delta':
|
|
if (event.stream === 'thought') {
|
|
appendThinkingDelta(event.text)
|
|
} else {
|
|
appendTextDelta(event.text)
|
|
}
|
|
break
|
|
case 'tool_call':
|
|
upsertAgentHarnessTool(event)
|
|
break
|
|
case 'produced_files':
|
|
setProducedFilesOnCurrentTurn(event.files)
|
|
break
|
|
case 'done':
|
|
markCurrentTurnDone()
|
|
break
|
|
case 'error':
|
|
appendErrorText(event.message)
|
|
break
|
|
case 'status':
|
|
break
|
|
}
|
|
}
|
|
processEventRef.current = processAgentHarnessStreamEvent
|
|
|
|
const activeTurnIdDep = options.activeTurnId ?? null
|
|
|
|
// On mount, on agent change, and whenever the listing reports a
|
|
// *new* active turn id, check whether the server has an in-flight
|
|
// turn for this agent and reattach to it. This catches three
|
|
// cases at once: the chat resilience flow (tab close/reopen),
|
|
// navigation between agents, AND queue drain (the server starts a
|
|
// new turn from a queued message → activeTurnId flips → attach).
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
const abortController = new AbortController()
|
|
// Reference the dep inside the body so biome's exhaustive-deps
|
|
// rule sees it consumed; the value is just an "any non-null
|
|
// active turn id" trigger — the actual id we attach to comes
|
|
// from the fresh fetchActiveHarnessTurn call below.
|
|
void activeTurnIdDep
|
|
|
|
const attemptResume = async () => {
|
|
// Track whether *we* started a stream in this run. When the
|
|
// early-return paths fire (no active turn, or a `send()` /
|
|
// earlier resume already owns `streamAbortRef`), the finally
|
|
// block must NOT touch streaming/turnIdRef/lastSeqRef —
|
|
// otherwise we clobber the in-flight stream's state and the
|
|
// Stop button drops out mid-turn while events keep arriving.
|
|
let weStartedStream = false
|
|
try {
|
|
const active = await fetchActiveHarnessTurn(agentId)
|
|
if (cancelled || !active || active.status !== 'running') return
|
|
if (streamAbortRef.current) return // someone else already owns the stream
|
|
|
|
// Stage a placeholder turn so the streamed events have a row
|
|
// to render into. The server now persists the kicking-off
|
|
// prompt on the active turn, so we render it as the user
|
|
// bubble immediately — no empty-bubble flicker when a queued
|
|
// message starts running.
|
|
setTurns((prev) => [
|
|
...prev,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
turnId: active.turnId,
|
|
userText: active.prompt ?? '',
|
|
parts: [],
|
|
done: false,
|
|
timestamp: active.startedAt,
|
|
},
|
|
])
|
|
textAccRef.current = ''
|
|
thinkAccRef.current = ''
|
|
turnIdRef.current = active.turnId
|
|
lastSeqRef.current = null
|
|
streamAbortRef.current = abortController
|
|
setStreaming(true)
|
|
weStartedStream = true
|
|
|
|
const response = await attachToHarnessTurn(agentId, {
|
|
turnId: active.turnId,
|
|
signal: abortController.signal,
|
|
})
|
|
if (!response.ok) return
|
|
await consumeSSEStream<AgentHarnessStreamEvent>(
|
|
response,
|
|
(event, meta) => {
|
|
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
|
|
processEventRef.current(event)
|
|
},
|
|
abortController.signal,
|
|
)
|
|
} catch {
|
|
// Resume is best-effort; transient errors fall back to the
|
|
// user starting a new turn manually.
|
|
} finally {
|
|
// Always release `streamAbortRef` if we owned it — even when
|
|
// the effect was cancelled mid-stream (a listing poll
|
|
// captured the next queue-drain turn id, for example). If we
|
|
// don't, the next effect run hits `if (streamAbortRef.current)
|
|
// return` against our now-aborted controller and never
|
|
// reattaches, leaving `streaming === true` with no live stream.
|
|
if (weStartedStream && streamAbortRef.current === abortController) {
|
|
streamAbortRef.current = null
|
|
}
|
|
// The other state (streaming flag, turn id, lastSeq) is the
|
|
// *current run's* lifecycle: only reset it on a clean exit.
|
|
// When `cancelled` is true the next run will set these
|
|
// itself, so resetting here would only cause a brief flicker.
|
|
if (!cancelled && weStartedStream) {
|
|
const finishedTurnId = turnIdRef.current
|
|
turnIdRef.current = null
|
|
lastSeqRef.current = null
|
|
setStreaming(false)
|
|
void invalidateAgentOutputsRef.current(
|
|
agentId,
|
|
finishedTurnId ?? undefined,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
void attemptResume()
|
|
return () => {
|
|
cancelled = true
|
|
abortController.abort()
|
|
}
|
|
}, [agentId, activeTurnIdDep])
|
|
|
|
/**
|
|
* Send the chat request and follow the 409-active-turn redirect
|
|
* once. Pulled out of `send` to keep its cognitive complexity in
|
|
* check — the retry adds a branch that biome counts heavily.
|
|
*/
|
|
const openSendStream = async (
|
|
targetAgentId: string,
|
|
text: string,
|
|
attachments: ServerAttachmentPayload[],
|
|
signal: AbortSignal,
|
|
): Promise<Response> => {
|
|
const initial = await chatWithHarnessAgent(
|
|
targetAgentId,
|
|
text,
|
|
signal,
|
|
attachments,
|
|
)
|
|
if (initial.status !== 409) return initial
|
|
// 409 means the server already has an active turn for this agent
|
|
// (a previous tab kicked one off and we're a fresh mount that
|
|
// missed the resume window). Attach to it instead of double-sending.
|
|
const body = (await initial.json()) as { turnId?: string }
|
|
if (!body.turnId) return initial
|
|
return attachToHarnessTurn(targetAgentId, {
|
|
turnId: body.turnId,
|
|
signal,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Pull session-key / turn-id off response headers and propagate to
|
|
* refs + the optimistic turn. Stamping `turnId` here lets the
|
|
* inline artifact card fall back to /files/turn/<id> on a resumed
|
|
* mount that missed the live `produced_files` event.
|
|
*/
|
|
const applyResponseHeadersToTurn = (response: Response) => {
|
|
const responseSessionKey =
|
|
response.headers.get('X-Session-Key') ??
|
|
response.headers.get('X-Session-Id')
|
|
if (responseSessionKey) {
|
|
sessionKeyRef.current = responseSessionKey
|
|
onSessionKeyChangeRef.current?.(responseSessionKey)
|
|
}
|
|
const responseTurnId = response.headers.get('X-Turn-Id')
|
|
if (!responseTurnId) return
|
|
turnIdRef.current = responseTurnId
|
|
lastSeqRef.current = null
|
|
setTurns((prev) => {
|
|
const last = prev[prev.length - 1]
|
|
if (!last) return prev
|
|
return [...prev.slice(0, -1), { ...last, turnId: responseTurnId }]
|
|
})
|
|
}
|
|
|
|
const send = async (input: string | SendInput) => {
|
|
const normalized: SendInput =
|
|
typeof input === 'string' ? { text: input } : input
|
|
const trimmed = normalized.text.trim()
|
|
const attachments = normalized.attachments ?? []
|
|
if (streaming) return
|
|
if (!trimmed && attachments.length === 0) return
|
|
|
|
const turn: AgentConversationTurn = {
|
|
id: crypto.randomUUID(),
|
|
userText: trimmed,
|
|
userAttachments:
|
|
normalized.attachmentPreviews &&
|
|
normalized.attachmentPreviews.length > 0
|
|
? normalized.attachmentPreviews
|
|
: undefined,
|
|
parts: [],
|
|
done: false,
|
|
timestamp: Date.now(),
|
|
}
|
|
setTurns((prev) => [...prev, turn])
|
|
setStreaming(true)
|
|
textAccRef.current = ''
|
|
thinkAccRef.current = ''
|
|
const abortController = new AbortController()
|
|
streamAbortRef.current = abortController
|
|
|
|
try {
|
|
const response = await openSendStream(
|
|
agentId,
|
|
trimmed,
|
|
attachments,
|
|
abortController.signal,
|
|
)
|
|
applyResponseHeadersToTurn(response)
|
|
if (!response.ok) {
|
|
const err = await response.text()
|
|
updateCurrentTurnParts((parts) => [
|
|
...parts,
|
|
{ kind: 'text', text: `Error: ${err}` },
|
|
])
|
|
return
|
|
}
|
|
await consumeSSEStream<AgentHarnessStreamEvent>(
|
|
response,
|
|
(event, meta) => {
|
|
if (typeof meta.seq === 'number') lastSeqRef.current = meta.seq
|
|
processAgentHarnessStreamEvent(event)
|
|
},
|
|
abortController.signal,
|
|
)
|
|
} catch (err) {
|
|
if (abortController.signal.aborted) return
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
updateCurrentTurnParts((parts) => [
|
|
...parts,
|
|
{ kind: 'text', text: `Error: ${msg}` },
|
|
])
|
|
} finally {
|
|
if (streamAbortRef.current === abortController) {
|
|
streamAbortRef.current = null
|
|
}
|
|
// Capture before nulling — the invalidation needs the turn id so
|
|
// useAgentTurnFiles consumers also flush, not just the agent-wide
|
|
// rail query.
|
|
const finishedTurnId = turnIdRef.current
|
|
turnIdRef.current = null
|
|
lastSeqRef.current = null
|
|
onCompleteRef.current?.()
|
|
setStreaming(false)
|
|
void invalidateAgentOutputs(agentId, finishedTurnId ?? undefined)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop button. The fetch abort only detaches *this* SSE subscriber
|
|
* now — the underlying turn would otherwise keep running on the
|
|
* server. So we explicitly cancel via the new endpoint, then unwind
|
|
* the local stream.
|
|
*/
|
|
const stop = async () => {
|
|
const turnId = turnIdRef.current ?? undefined
|
|
streamAbortRef.current?.abort()
|
|
streamAbortRef.current = null
|
|
try {
|
|
await cancelHarnessTurn(agentId, {
|
|
turnId,
|
|
reason: 'user pressed stop',
|
|
})
|
|
} catch {
|
|
// Best-effort — UI already aborted.
|
|
}
|
|
}
|
|
|
|
const resetConversation = () => {
|
|
void stop()
|
|
setTurns([])
|
|
setStreaming(false)
|
|
}
|
|
|
|
return {
|
|
turns,
|
|
streaming,
|
|
sessionKey: sessionKeyRef.current,
|
|
send,
|
|
stop,
|
|
resetConversation,
|
|
}
|
|
}
|