Files
Dani Akash db5e55a174 feat(agent-files): expose openclaw produced files inline + outputs rail (#946)
* 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.
2026-05-05 19:48:28 +05:30

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,
}
}