feat(tui): move assistant content into per-part quark slots
This commit is contained in:
parent
2635451bbf
commit
6d043d9155
11 changed files with 547 additions and 328 deletions
94
docs/design/quark-message-content.md
Normal file
94
docs/design/quark-message-content.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Quark-Owned Message Content Slots
|
||||||
|
|
||||||
|
Status: implemented; drive parity pending
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Assistant message content currently lives as unkeyed arrays inside the
|
||||||
|
DataProvider Solid store. Streaming deltas mutate those arrays through
|
||||||
|
`produce`, and every consumer that resolves a part re-scans `content`
|
||||||
|
(`findLast`, `filter(type)[ordinal]`), so per-delta work scales with message
|
||||||
|
size instead of delta size.
|
||||||
|
|
||||||
|
This experiment changes only content ownership: each assistant message's parts
|
||||||
|
become one Quark keyed collection with stable per-part slots. Streaming events
|
||||||
|
already name the slot on the wire — `ordinal` for text and reasoning, `callID`
|
||||||
|
for tools — so deltas address slots directly instead of being rediscovered.
|
||||||
|
|
||||||
|
Message metadata (finish, usage, retry, error), the event protocol, timeline
|
||||||
|
rows, and view components' rendering logic remain unchanged.
|
||||||
|
|
||||||
|
The experiment succeeds only if all checks pass:
|
||||||
|
|
||||||
|
1. Existing TUI data and row tests preserve their behavior.
|
||||||
|
2. Per-delta work is O(1) in message size: one slot publication per delta,
|
||||||
|
zero structural publications, one markdown recompute scoped to the part.
|
||||||
|
3. The drive script passes unchanged.
|
||||||
|
|
||||||
|
## Ownership Boundary
|
||||||
|
|
||||||
|
```
|
||||||
|
server events
|
||||||
|
|
|
||||||
|
v
|
||||||
|
DataProvider
|
||||||
|
├── Solid store: session/message metadata (unchanged)
|
||||||
|
└── SessionContent: Keyed<Part> per assistant message <- this experiment
|
||||||
|
| |
|
||||||
|
| slots (structure) | slot (value channel)
|
||||||
|
v v
|
||||||
|
part row resolution TextPart / ReasoningPart / ToolPart
|
||||||
|
```
|
||||||
|
|
||||||
|
- Part identity: `text:{ordinal}` and `reasoning:{ordinal}` from event
|
||||||
|
ordinals, tool parts by `callID`. These match the synthetic part IDs the
|
||||||
|
timeline already uses, so `resolvePart`'s positional bridge disappears.
|
||||||
|
- Deltas are value-channel publications: `content.modify(key, ...)`. The
|
||||||
|
parts `<For>`/group structure never re-runs on a delta.
|
||||||
|
- Part starts are structural insertions; `ended` events publish the
|
||||||
|
authoritative final value into the same slot.
|
||||||
|
- Full sync (`message.sync`) seeds each message's collection with `set(parts)`
|
||||||
|
derived from the fetched payload, assigning ordinals per type in order.
|
||||||
|
- Revert, session removal, and model-switch refetch drop or reseed collections.
|
||||||
|
- Cold consumers (transcript export, copy-last-response, message navigation,
|
||||||
|
permission lookup) read `values()` — plain arrays, same shapes as before.
|
||||||
|
|
||||||
|
## Part Layout
|
||||||
|
|
||||||
|
Tool identity fields are immutable; streamed fields are mutable. The field
|
||||||
|
diff mask means a tool status change skips text-driven work and vice versa.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PartLayout = Layout.keyedUnion({
|
||||||
|
key: Layout.key("partID", Layout.string),
|
||||||
|
tag: "type",
|
||||||
|
variants: {
|
||||||
|
text: Layout.struct({ text: Layout.string }),
|
||||||
|
reasoning: Layout.struct({ text: Layout.string, state: ..., time: ... }),
|
||||||
|
tool: Layout.struct({
|
||||||
|
id: Layout.immutable(Layout.string),
|
||||||
|
name: Layout.immutable(Layout.string),
|
||||||
|
state: ..., time: ..., executed: ..., providerState: ...,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Store values are the client `SessionMessageAssistant*` content shapes plus the
|
||||||
|
synthetic `partID`; `values()` strips nothing because consumers already ignore
|
||||||
|
unknown fields.
|
||||||
|
|
||||||
|
## What Is Not In Scope
|
||||||
|
|
||||||
|
- Message metadata stays in the Solid store.
|
||||||
|
- No changes to server events or the client package types.
|
||||||
|
- No lazy or pull-based consumers; slots publish eagerly as today.
|
||||||
|
- Timeline row ownership is the previous experiment and is unchanged.
|
||||||
|
|
||||||
|
## Measurement
|
||||||
|
|
||||||
|
Instrument with `Keyed.Metrics`: a streaming test drives N deltas into a
|
||||||
|
message with M existing parts and asserts slot publications == N and
|
||||||
|
structural publications == parts started, independent of M. Markdown
|
||||||
|
recompute counts are asserted through the part component render counters in
|
||||||
|
the existing test harness.
|
||||||
|
|
@ -1,10 +1,29 @@
|
||||||
import { For, from, type Accessor, type JSX } from "solid-js"
|
import { createMemo, For, from, type Accessor, type JSX } from "solid-js"
|
||||||
|
import type { Keyed } from "./keyed"
|
||||||
import type { Readable } from "./reactivity"
|
import type { Readable } from "./reactivity"
|
||||||
|
|
||||||
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
||||||
return from(readable, readable())
|
return from(readable, readable())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive accessor for one keyed slot. Tracks structure only until the slot
|
||||||
|
* exists; afterwards value changes flow through the slot alone, so unrelated
|
||||||
|
* structural churn cannot re-render the consumer.
|
||||||
|
*/
|
||||||
|
export function useSlot<A, Key>(keyed: Keyed.Keyed<A, Key>, key: () => Key): Accessor<A | undefined> {
|
||||||
|
const structure = useValue(keyed.slots)
|
||||||
|
const slot = createMemo(() => {
|
||||||
|
structure()
|
||||||
|
return keyed.get(key())
|
||||||
|
})
|
||||||
|
const value = createMemo(() => {
|
||||||
|
const current = slot()
|
||||||
|
return current ? useValue(current) : undefined
|
||||||
|
})
|
||||||
|
return () => value()?.()
|
||||||
|
}
|
||||||
|
|
||||||
export function KeyedFor<A>(props: {
|
export function KeyedFor<A>(props: {
|
||||||
readonly each: Accessor<readonly Readable<A>[]>
|
readonly each: Accessor<readonly Readable<A>[]>
|
||||||
readonly fallback?: JSX.Element
|
readonly fallback?: JSX.Element
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,6 @@ import type {
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
SessionMessageAssistantReasoning,
|
|
||||||
SessionMessageAssistantText,
|
|
||||||
SessionMessageAssistantTool,
|
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionPendingInfo,
|
SessionPendingInfo,
|
||||||
ShellInfo,
|
ShellInfo,
|
||||||
|
|
@ -33,6 +30,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useClient } from "./client"
|
import { useClient } from "./client"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
import { SessionContent } from "../routes/session/content"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
|
|
@ -146,6 +144,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
|
// Assistant content lives in per-message keyed part slots, not the Solid
|
||||||
|
// store: streaming deltas publish one slot instead of reconciling arrays.
|
||||||
|
const content = SessionContent.make()
|
||||||
const sync = createSync()
|
const sync = createSync()
|
||||||
|
|
||||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||||
|
|
@ -199,20 +200,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||||
return item?.type === "compaction" ? item : undefined
|
return item?.type === "compaction" ? item : undefined
|
||||||
},
|
},
|
||||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
|
||||||
return assistant?.content.findLast(
|
|
||||||
(item): item is SessionMessageAssistantTool =>
|
|
||||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
|
||||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
|
||||||
},
|
|
||||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
|
||||||
return assistant?.content.findLast(
|
|
||||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function index(sessionID: string) {
|
function index(sessionID: string) {
|
||||||
|
|
@ -269,6 +256,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
|
|
||||||
function removeSession(sessionID: string) {
|
function removeSession(sessionID: string) {
|
||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
|
content.drop(sessionID)
|
||||||
sync.invalidate(`session:${sessionID}`)
|
sync.invalidate(`session:${sessionID}`)
|
||||||
sync.invalidate(`session.pending:${sessionID}`)
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
sync.invalidate(`session.message:${sessionID}`)
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
|
|
@ -353,6 +341,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
void client.api.session
|
void client.api.session
|
||||||
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||||
.then((item) => {
|
.then((item) => {
|
||||||
|
if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = index.get(item.id)
|
const position = index.get(item.id)
|
||||||
if (position === undefined) return message.append(draft, index, item)
|
if (position === undefined) return message.append(draft, index, item)
|
||||||
|
|
@ -540,143 +529,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.text.started":
|
case "session.text.started": {
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
const partID = SessionContent.textID(event.data.ordinal)
|
||||||
type: "text",
|
if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID })
|
||||||
text: "",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "session.text.delta":
|
case "session.text.delta":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content
|
||||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||||
if (match) match.text += event.data.delta
|
?.modify(SessionContent.textID(event.data.ordinal), (part) =>
|
||||||
})
|
part.type === "text" ? { ...part, text: part.text + event.data.delta } : part,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case "session.text.ended":
|
case "session.text.ended":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content
|
||||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||||
if (match) match.text = event.data.text
|
?.modify(SessionContent.textID(event.data.ordinal), (part) =>
|
||||||
})
|
part.type === "text" ? { ...part, text: event.data.text } : part,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case "session.tool.input.started":
|
case "session.tool.input.started": {
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
if (!parts.has(event.data.callID))
|
||||||
|
parts.insert({
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: event.data.callID,
|
id: event.data.callID,
|
||||||
name: event.data.name,
|
name: event.data.name,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
state: { status: "streaming", input: "" },
|
state: { status: "streaming", input: "" },
|
||||||
|
partID: event.data.callID,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "session.tool.input.delta":
|
case "session.tool.input.delta":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool" || part.state.status !== "streaming") return part
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } }
|
||||||
event.data.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.tool.input.ended":
|
case "session.tool.input.ended":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool" || part.state.status !== "streaming") return part
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return { ...part, state: { ...part.state, input: event.data.text } }
|
||||||
event.data.callID,
|
|
||||||
)
|
|
||||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.tool.called":
|
case "session.tool.called":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool") return part
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return {
|
||||||
event.data.callID,
|
...part,
|
||||||
)
|
time: { ...part.time, ran: event.created },
|
||||||
if (!match) return
|
executed: event.data.executed,
|
||||||
match.time.ran = event.created
|
providerState: event.data.state,
|
||||||
match.executed = event.data.executed
|
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||||
match.providerState = event.data.state
|
}
|
||||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.tool.progress":
|
case "session.tool.progress":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool" || part.state.status !== "running") return part
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return {
|
||||||
event.data.callID,
|
...part,
|
||||||
)
|
state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
|
||||||
if (match?.state.status !== "running") return
|
}
|
||||||
match.state.structured = event.data.structured
|
|
||||||
match.state.content = [...event.data.content]
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.tool.success":
|
case "session.tool.success":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool" || part.state.status !== "running") return part
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return {
|
||||||
event.data.callID,
|
...part,
|
||||||
)
|
state: {
|
||||||
if (match?.state.status !== "running") return
|
status: "completed",
|
||||||
match.state = {
|
input: part.state.input,
|
||||||
status: "completed",
|
structured: event.data.structured,
|
||||||
input: match.state.input,
|
content: [...event.data.content],
|
||||||
structured: event.data.structured,
|
result: event.data.result,
|
||||||
content: [...event.data.content],
|
},
|
||||||
result: event.data.result,
|
executed: event.data.executed || part.executed === true,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
|
time: { ...part.time, completed: event.created },
|
||||||
}
|
}
|
||||||
match.executed = event.data.executed || match.executed === true
|
|
||||||
match.providerResultState = event.data.resultState
|
|
||||||
match.time.completed = event.created
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.tool.failed":
|
case "session.tool.failed":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||||
const match = message.latestTool(
|
if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running"))
|
||||||
message.assistant(draft, index, event.data.assistantMessageID),
|
return part
|
||||||
event.data.callID,
|
return {
|
||||||
)
|
...part,
|
||||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
state: {
|
||||||
match.state = {
|
status: "error",
|
||||||
status: "error",
|
error: event.data.error,
|
||||||
error: event.data.error,
|
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
structured: part.state.status === "running" ? part.state.structured : {},
|
||||||
structured: match.state.status === "running" ? match.state.structured : {},
|
content: part.state.status === "running" ? part.state.content : [],
|
||||||
content: match.state.status === "running" ? match.state.content : [],
|
result: event.data.result,
|
||||||
result: event.data.result,
|
},
|
||||||
|
executed: event.data.executed || part.executed === true,
|
||||||
|
providerResultState: event.data.resultState,
|
||||||
|
time: { ...part.time, completed: event.created },
|
||||||
}
|
}
|
||||||
match.executed = event.data.executed || match.executed === true
|
|
||||||
match.providerResultState = event.data.resultState
|
|
||||||
match.time.completed = event.created
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.reasoning.started":
|
case "session.reasoning.started": {
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
const partID = SessionContent.reasoningID(event.data.ordinal)
|
||||||
|
if (!parts.has(partID))
|
||||||
|
parts.insert({
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: "",
|
text: "",
|
||||||
state: event.data.state,
|
state: event.data.state,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
|
partID,
|
||||||
})
|
})
|
||||||
})
|
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case "session.reasoning.delta":
|
case "session.reasoning.delta":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content
|
||||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||||
if (match) match.text += event.data.delta
|
?.modify(SessionContent.reasoningID(event.data.ordinal), (part) =>
|
||||||
})
|
part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case "session.reasoning.ended":
|
case "session.reasoning.ended":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
content
|
||||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||||
if (match) {
|
?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => {
|
||||||
match.text = event.data.text
|
if (part.type !== "reasoning") return part
|
||||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
return {
|
||||||
if (event.data.state !== undefined) match.state = event.data.state
|
...part,
|
||||||
}
|
text: event.data.text,
|
||||||
})
|
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||||
|
state: event.data.state !== undefined ? event.data.state : part.state,
|
||||||
|
}
|
||||||
|
})
|
||||||
break
|
break
|
||||||
case "session.retry.scheduled":
|
case "session.retry.scheduled":
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
|
|
@ -745,7 +733,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||||
if (position === -1) return
|
if (position === -1) return
|
||||||
for (const item of draft.splice(position)) index.delete(item.id)
|
for (const item of draft.splice(position)) {
|
||||||
|
index.delete(item.id)
|
||||||
|
content.drop(event.data.sessionID, item.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.compaction.delta":
|
case "session.compaction.delta":
|
||||||
|
|
@ -964,9 +955,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||||
).data.toReversed()
|
).data.toReversed()
|
||||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||||
|
content.drop(sessionID)
|
||||||
|
for (const item of messages) {
|
||||||
|
if (item.type === "assistant") content.seed(sessionID, item.id, item.content)
|
||||||
|
}
|
||||||
setStore("session", "message", sessionID, reconcile(messages))
|
setStore("session", "message", sessionID, reconcile(messages))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
parts(sessionID: string, messageID: string) {
|
||||||
|
return content.ensure(sessionID, messageID)
|
||||||
|
},
|
||||||
invalidate(sessionID: string) {
|
invalidate(sessionID: string) {
|
||||||
sync.invalidate(`session.message:${sessionID}`)
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
91
packages/tui/src/routes/session/content.ts
Normal file
91
packages/tui/src/routes/session/content.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { SessionMessageAssistant } from "@opencode-ai/client"
|
||||||
|
import { Keyed, Layout } from "effect-quark"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-part reactive slots for assistant message content.
|
||||||
|
*
|
||||||
|
* Streaming events name their part on the wire (text/reasoning by ordinal,
|
||||||
|
* tools by callID); each assistant message owns one keyed collection so a
|
||||||
|
* delta publishes exactly one slot instead of reconciling a content array.
|
||||||
|
* See docs/design/quark-message-content.md.
|
||||||
|
*/
|
||||||
|
export namespace SessionContent {
|
||||||
|
type ContentPart = SessionMessageAssistant["content"][number]
|
||||||
|
export type Part = ContentPart & { readonly partID: string }
|
||||||
|
export type Parts = Keyed.Keyed<Part, string>
|
||||||
|
|
||||||
|
// Streamed sub-objects are replaced immutably on change, so reference
|
||||||
|
// equality is the correct (and cheapest) field comparator for them.
|
||||||
|
const reference: Layout.Field<unknown> = { equivalent: Object.is }
|
||||||
|
|
||||||
|
const PartLayout = Layout.keyedUnion({
|
||||||
|
key: Layout.key("partID", Layout.string),
|
||||||
|
tag: "type",
|
||||||
|
variants: {
|
||||||
|
text: Layout.struct({ text: Layout.string }),
|
||||||
|
reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }),
|
||||||
|
tool: Layout.struct({
|
||||||
|
id: Layout.immutable(Layout.string),
|
||||||
|
name: Layout.immutable(Layout.string),
|
||||||
|
executed: reference,
|
||||||
|
providerState: reference,
|
||||||
|
providerResultState: reference,
|
||||||
|
state: reference,
|
||||||
|
time: reference,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The layout describes the reactive fields of the client content shapes;
|
||||||
|
// the plan is typed against those shapes at this single boundary.
|
||||||
|
const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan<Part, string>
|
||||||
|
|
||||||
|
export function textID(ordinal: number) {
|
||||||
|
return `text:${ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reasoningID(ordinal: number) {
|
||||||
|
return `reasoning:${ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assign the synthetic part IDs used across the TUI to a fetched content array. */
|
||||||
|
export function withPartIDs(content: readonly ContentPart[]): Part[] {
|
||||||
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
|
return content.map((part) => {
|
||||||
|
if (part.type === "tool") return { ...part, partID: part.id }
|
||||||
|
return { ...part, partID: `${part.type}:${ordinals[part.type]++}` }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
|
||||||
|
const collections = new Map<string, Parts>()
|
||||||
|
const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(sessionID: string, messageID: string) {
|
||||||
|
return collections.get(id(sessionID, messageID))
|
||||||
|
},
|
||||||
|
ensure(sessionID: string, messageID: string) {
|
||||||
|
const key = id(sessionID, messageID)
|
||||||
|
const existing = collections.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
const created = PartPlan.make([], options)
|
||||||
|
collections.set(key, created)
|
||||||
|
return created
|
||||||
|
},
|
||||||
|
seed(sessionID: string, messageID: string, content: readonly ContentPart[]) {
|
||||||
|
this.ensure(sessionID, messageID).set(withPartIDs(content))
|
||||||
|
},
|
||||||
|
/** Drop one message's collection, or every collection for a session. */
|
||||||
|
drop(sessionID: string, messageID?: string) {
|
||||||
|
if (messageID !== undefined) {
|
||||||
|
collections.delete(id(sessionID, messageID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prefix = `${sessionID}\u0000`
|
||||||
|
for (const key of [...collections.keys()]) {
|
||||||
|
if (key.startsWith(prefix)) collections.delete(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,9 @@ export function DialogMessage(props: {
|
||||||
value.type === "user"
|
value.type === "user"
|
||||||
? value.text
|
? value.text
|
||||||
: value.type === "assistant"
|
: value.type === "assistant"
|
||||||
? value.content
|
? data.session.message
|
||||||
|
.parts(props.sessionID, props.messageID)
|
||||||
|
.values()
|
||||||
.filter((content) => content.type === "text")
|
.filter((content) => content.type === "text")
|
||||||
.map((content) => content.text)
|
.map((content) => content.text)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
useContext,
|
useContext,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { KeyedFor } from "effect-quark/solid"
|
import { KeyedFor, useSlot, useValue } from "effect-quark/solid"
|
||||||
|
import { SessionContent } from "./content"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { EOL, tmpdir } from "node:os"
|
import { EOL, tmpdir } from "node:os"
|
||||||
import { mkdir, writeFile } from "node:fs/promises"
|
import { mkdir, writeFile } from "node:fs/promises"
|
||||||
|
|
@ -74,7 +75,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { usePathFormatter } from "../../context/path-format"
|
import { usePathFormatter } from "../../context/path-format"
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import { createSessionRows } from "./rows"
|
import { createSessionRows } from "./rows"
|
||||||
import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline"
|
import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline"
|
||||||
import { switchLabel } from "../../util/model"
|
import { switchLabel } from "../../util/model"
|
||||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||||
import { stringWidth } from "../../util/string-width"
|
import { stringWidth } from "../../util/string-width"
|
||||||
|
|
@ -312,6 +313,11 @@ export function Session() {
|
||||||
direction,
|
direction,
|
||||||
children: scroll.getChildren(),
|
children: scroll.getChildren(),
|
||||||
messages: messages(),
|
messages: messages(),
|
||||||
|
hasText: (messageID) =>
|
||||||
|
data.session.message
|
||||||
|
.parts(route.sessionID, messageID)
|
||||||
|
.values()
|
||||||
|
.some((part) => part.type === "text" && part.text.trim()),
|
||||||
scrollTop: scroll.scrollTop,
|
scrollTop: scroll.scrollTop,
|
||||||
viewportY: scroll.viewport.y,
|
viewportY: scroll.viewport.y,
|
||||||
currentID: navigationMessage(),
|
currentID: navigationMessage(),
|
||||||
|
|
@ -681,7 +687,10 @@ export function Session() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const textParts = lastAssistantMessage.content.filter((part) => part.type === "text")
|
const textParts = data.session.message
|
||||||
|
.parts(route.sessionID, lastAssistantMessage.id)
|
||||||
|
.values()
|
||||||
|
.filter((part) => part.type === "text")
|
||||||
if (textParts.length === 0) {
|
if (textParts.length === 0) {
|
||||||
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
|
|
@ -719,7 +728,9 @@ export function Session() {
|
||||||
try {
|
try {
|
||||||
const sessionData = session()
|
const sessionData = session()
|
||||||
if (!sessionData) return
|
if (!sessionData) return
|
||||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) =>
|
||||||
|
data.session.message.parts(route.sessionID, messageID).values(),
|
||||||
|
)
|
||||||
await clipboard.write?.(transcript)
|
await clipboard.write?.(transcript)
|
||||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -746,7 +757,9 @@ export function Session() {
|
||||||
|
|
||||||
const content =
|
const content =
|
||||||
options.format === "markdown"
|
options.format === "markdown"
|
||||||
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
|
||||||
|
data.session.message.parts(route.sessionID, messageID).values(),
|
||||||
|
)
|
||||||
: await (async () => {
|
: await (async () => {
|
||||||
if (options.debug) {
|
if (options.debug) {
|
||||||
const events: { readonly created: number }[] = []
|
const events: { readonly created: number }[] = []
|
||||||
|
|
@ -934,11 +947,15 @@ export function Session() {
|
||||||
<SessionRowView
|
<SessionRowView
|
||||||
row={row()}
|
row={row()}
|
||||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||||
|
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
|
||||||
boundaryID={boundaries()[index()]}
|
boundaryID={boundaries()[index()]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</KeyedFor>
|
</KeyedFor>
|
||||||
<BackgroundToolHint messages={messages()} />
|
<BackgroundToolHint
|
||||||
|
messages={messages()}
|
||||||
|
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
|
||||||
|
/>
|
||||||
<Show when={session()?.revert?.messageID}>
|
<Show when={session()?.revert?.messageID}>
|
||||||
<RevertMessage
|
<RevertMessage
|
||||||
count={
|
count={
|
||||||
|
|
@ -1028,6 +1045,7 @@ export function Session() {
|
||||||
function SessionRowView(props: {
|
function SessionRowView(props: {
|
||||||
row: SessionRow
|
row: SessionRow
|
||||||
message: (messageID: string) => SessionMessageInfo | undefined
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
parts: (messageID: string) => SessionContent.Parts
|
||||||
boundaryID?: string
|
boundaryID?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1042,10 +1060,17 @@ function SessionRowView(props: {
|
||||||
<CompactionQueued />
|
<CompactionQueued />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
{(row) => (
|
||||||
|
<SessionReasoningGroupView
|
||||||
|
refs={row().refs}
|
||||||
|
completed={row().completed}
|
||||||
|
message={props.message}
|
||||||
|
parts={props.parts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
|
|
@ -1054,6 +1079,7 @@ function SessionRowView(props: {
|
||||||
pending={row().pending}
|
pending={row().pending}
|
||||||
completed={row().completed}
|
completed={row().completed}
|
||||||
message={props.message}
|
message={props.message}
|
||||||
|
parts={props.parts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
|
|
@ -1073,21 +1099,29 @@ function SessionRowView(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
function BackgroundToolHint(props: {
|
||||||
|
messages: SessionMessageInfo[]
|
||||||
|
parts: (messageID: string) => SessionContent.Parts
|
||||||
|
}) {
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
const shortcut = Keymap.useShortcut("session.background")
|
const shortcut = Keymap.useShortcut("session.background")
|
||||||
const visible = createMemo(() => {
|
const current = createMemo(() =>
|
||||||
const current = props.messages.findLast(
|
props.messages.findLast(
|
||||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||||
)
|
),
|
||||||
return (
|
)
|
||||||
current?.content.some((part) => {
|
const parts = createMemo(() => {
|
||||||
|
const message = current()
|
||||||
|
return message ? useValue(props.parts(message.id).values) : undefined
|
||||||
|
})
|
||||||
|
const visible = createMemo(
|
||||||
|
() =>
|
||||||
|
parts()?.()?.some((part) => {
|
||||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||||
const display = toolDisplay(part.name)
|
const display = toolDisplay(part.name)
|
||||||
return display === "shell" || display === "subagent"
|
return display === "shell" || display === "subagent"
|
||||||
}) ?? false
|
}) ?? false,
|
||||||
)
|
)
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<Show when={visible() && shortcut()}>
|
<Show when={visible() && shortcut()}>
|
||||||
{(value) => (
|
{(value) => (
|
||||||
|
|
@ -1127,13 +1161,15 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
function SessionPartView(props: {
|
||||||
|
partRef: PartRef
|
||||||
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
parts: (messageID: string) => SessionContent.Parts
|
||||||
|
}) {
|
||||||
const message = createMemo(() => props.message(props.partRef.messageID))
|
const message = createMemo(() => props.message(props.partRef.messageID))
|
||||||
const part = createMemo(() => {
|
// One reactive slot per part: unrelated deltas in the same message cannot
|
||||||
const item = message()
|
// re-render this row.
|
||||||
if (item?.type !== "assistant") return
|
const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID)
|
||||||
return resolvePart(item, props.partRef.partID)
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<Show when={part()}>
|
<Show when={part()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
|
|
@ -1161,17 +1197,26 @@ function SessionReasoningGroupView(props: {
|
||||||
refs: readonly PartRef[]
|
refs: readonly PartRef[]
|
||||||
completed: boolean
|
completed: boolean
|
||||||
message: (messageID: string) => SessionMessageInfo | undefined
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
parts: (messageID: string) => SessionContent.Parts
|
||||||
}) {
|
}) {
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax } = useTheme()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
|
// Slot accessors are created per ref list; value changes flow through the
|
||||||
|
// individual slots without re-running the accessor construction.
|
||||||
|
const accessors = createMemo(() =>
|
||||||
|
props.refs.map((ref) => ({
|
||||||
|
ref,
|
||||||
|
part: useSlot(props.parts(ref.messageID), () => ref.partID),
|
||||||
|
})),
|
||||||
|
)
|
||||||
const parts = createMemo(() =>
|
const parts = createMemo(() =>
|
||||||
props.refs.flatMap((ref) => {
|
accessors().flatMap((entry) => {
|
||||||
const message = props.message(ref.messageID)
|
const message = props.message(entry.ref.messageID)
|
||||||
if (message?.type !== "assistant") return []
|
if (message?.type !== "assistant") return []
|
||||||
const part = resolvePart(message, ref.partID)
|
const part = entry.part()
|
||||||
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
|
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
|
||||||
return [{ message, part }]
|
return [{ message, part }]
|
||||||
}),
|
}),
|
||||||
|
|
@ -1196,7 +1241,11 @@ function SessionReasoningGroupView(props: {
|
||||||
<Show when={parts().length > 0}>
|
<Show when={parts().length > 0}>
|
||||||
<Show
|
<Show
|
||||||
when={ctx.thinkingMode() === "hide"}
|
when={ctx.thinkingMode() === "hide"}
|
||||||
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
|
fallback={
|
||||||
|
<For each={props.refs}>
|
||||||
|
{(ref) => <SessionPartView partRef={ref} message={props.message} parts={props.parts} />}
|
||||||
|
</For>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<box flexDirection="column" flexShrink={0}>
|
<box flexDirection="column" flexShrink={0}>
|
||||||
<InlineToolRow
|
<InlineToolRow
|
||||||
|
|
@ -1232,15 +1281,14 @@ function SessionReasoningGroupView(props: {
|
||||||
<box paddingLeft={3}>
|
<box paddingLeft={3}>
|
||||||
<For each={props.refs}>
|
<For each={props.refs}>
|
||||||
{(ref) => {
|
{(ref) => {
|
||||||
const message = createMemo(() => {
|
const slot = useSlot(props.parts(ref.messageID), () => ref.partID)
|
||||||
const item = props.message(ref.messageID)
|
|
||||||
return item?.type === "assistant" ? item : undefined
|
|
||||||
})
|
|
||||||
const part = createMemo(() => {
|
const part = createMemo(() => {
|
||||||
const item = message()
|
const item = slot()
|
||||||
if (!item) return undefined
|
return item?.type === "reasoning" ? item : undefined
|
||||||
const part = resolvePart(item, ref.partID)
|
})
|
||||||
return part?.type === "reasoning" ? part : undefined
|
const messageCompleted = createMemo(() => {
|
||||||
|
const item = props.message(ref.messageID)
|
||||||
|
return item?.type === "assistant" && item.time.completed !== undefined
|
||||||
})
|
})
|
||||||
const content = createMemo(() => {
|
const content = createMemo(() => {
|
||||||
const item = part()
|
const item = part()
|
||||||
|
|
@ -1258,7 +1306,7 @@ function SessionReasoningGroupView(props: {
|
||||||
<code
|
<code
|
||||||
filetype="markdown"
|
filetype="markdown"
|
||||||
drawUnstyledText={false}
|
drawUnstyledText={false}
|
||||||
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
|
streaming={part()?.time?.completed === undefined && !messageCompleted()}
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
content={content()}
|
content={content()}
|
||||||
conceal={ctx.markdownMode() === "rendered"}
|
conceal={ctx.markdownMode() === "rendered"}
|
||||||
|
|
@ -1283,22 +1331,27 @@ function SessionGroupView(props: {
|
||||||
pending: readonly PartRef[]
|
pending: readonly PartRef[]
|
||||||
completed: boolean
|
completed: boolean
|
||||||
message: (messageID: string) => SessionMessageInfo | undefined
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
parts: (messageID: string) => SessionContent.Parts
|
||||||
}) {
|
}) {
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
const ctx = use()
|
const ctx = use()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
const [expanded, setExpanded] = createSignal(false)
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
const [hover, setHover] = createSignal(false)
|
const [hover, setHover] = createSignal(false)
|
||||||
const parts = (refs: readonly PartRef[]) =>
|
// Slot accessors per ref list: tool state changes flow through individual
|
||||||
refs.flatMap((ref) => {
|
// slots; the accessor lists rebuild only when the refs themselves change.
|
||||||
const message = props.message(ref.messageID)
|
const slots = (refs: () => readonly PartRef[]) =>
|
||||||
if (message?.type !== "assistant") return []
|
createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID)))
|
||||||
const part = resolvePart(message, ref.partID)
|
const groupedSlots = slots(() => props.refs)
|
||||||
|
const pendingSlots = slots(() => props.pending)
|
||||||
|
const parts = (accessors: readonly (() => SessionContent.Part | undefined)[]) =>
|
||||||
|
accessors.flatMap((accessor) => {
|
||||||
|
const part = accessor()
|
||||||
if (part?.type !== "tool") return []
|
if (part?.type !== "tool") return []
|
||||||
return [part]
|
return [part]
|
||||||
})
|
})
|
||||||
const grouped = createMemo(() => parts(props.refs))
|
const grouped = createMemo(() => parts(groupedSlots()))
|
||||||
const pending = createMemo(() => parts(props.pending))
|
const pending = createMemo(() => parts(pendingSlots()))
|
||||||
const label = createMemo(() => {
|
const label = createMemo(() => {
|
||||||
const counts = grouped().reduce<Record<string, number>>((result, part) => {
|
const counts = grouped().reduce<Record<string, number>>((result, part) => {
|
||||||
const tool = toolDisplay(part.name)
|
const tool = toolDisplay(part.name)
|
||||||
|
|
@ -1713,124 +1766,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
|
||||||
const ctx = use()
|
|
||||||
const local = useLocal()
|
|
||||||
const { themeV2 } = useTheme().contextual("elevated")
|
|
||||||
const model = createMemo(
|
|
||||||
() =>
|
|
||||||
ctx
|
|
||||||
.models()
|
|
||||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
|
||||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const final = createMemo(() => {
|
|
||||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
|
||||||
})
|
|
||||||
|
|
||||||
const duration = createMemo(() => {
|
|
||||||
if (!final()) return 0
|
|
||||||
if (!props.message.time.completed) return 0
|
|
||||||
return props.message.time.completed - props.message.time.created
|
|
||||||
})
|
|
||||||
|
|
||||||
const exploration = createMemo(() => {
|
|
||||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
|
||||||
if (!ctx.groupExploration()) return grouped
|
|
||||||
const runs = props.message.content
|
|
||||||
.map((part) =>
|
|
||||||
part.type === "tool" &&
|
|
||||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
|
||||||
part.state.status !== "streaming"
|
|
||||||
? part
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
.reduce<SessionMessageAssistantTool[][]>(
|
|
||||||
(runs, part) => {
|
|
||||||
if (part) runs[runs.length - 1].push(part)
|
|
||||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
|
||||||
return runs
|
|
||||||
},
|
|
||||||
[[]],
|
|
||||||
)
|
|
||||||
.filter((run) => run.length > 0)
|
|
||||||
for (const run of runs) {
|
|
||||||
const summary = {
|
|
||||||
parts: run,
|
|
||||||
active: false,
|
|
||||||
}
|
|
||||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
|
||||||
}
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<For each={props.message.content}>
|
|
||||||
{(content, index) => (
|
|
||||||
<Switch>
|
|
||||||
<Match when={content.type === "text"}>
|
|
||||||
<TextPart
|
|
||||||
part={content as SessionMessageAssistantText}
|
|
||||||
last={index() === props.message.content.length - 1}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={content.type === "reasoning"}>
|
|
||||||
<ReasoningPart
|
|
||||||
part={content as SessionMessageAssistantReasoning}
|
|
||||||
message={props.message}
|
|
||||||
last={index() === props.message.content.length - 1}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={content.type === "tool"}>
|
|
||||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
|
||||||
<Show
|
|
||||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
|
||||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
|
||||||
>
|
|
||||||
{(summary) => <ExplorationSummary {...summary()} />}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={props.message.error}>
|
|
||||||
<box
|
|
||||||
border={["left"]}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
backgroundColor={themeV2.background()}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={themeV2.text.feedback.error()}
|
|
||||||
>
|
|
||||||
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<AssistantRetry retry={props.message.retry} />
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.last || final() || props.message.error}>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text>
|
|
||||||
<span
|
|
||||||
style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}
|
|
||||||
>
|
|
||||||
{Locale.titlecase(props.message.agent)}
|
|
||||||
</span>
|
|
||||||
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
|
|
||||||
<Show when={duration()}>
|
|
||||||
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||||
const { themeV2 } = useTheme()
|
const { themeV2 } = useTheme()
|
||||||
return (
|
return (
|
||||||
|
|
@ -3035,13 +2970,18 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
|
function formatSessionTranscript(
|
||||||
|
session: SessionInfo,
|
||||||
|
messages: SessionMessageInfo[],
|
||||||
|
thinking: boolean,
|
||||||
|
partsOf: (messageID: string) => readonly SessionContent.Part[],
|
||||||
|
) {
|
||||||
const body = messages.flatMap((message) => {
|
const body = messages.flatMap((message) => {
|
||||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||||
if (message.type === "shell")
|
if (message.type === "shell")
|
||||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||||
if (message.type !== "assistant") return []
|
if (message.type !== "assistant") return []
|
||||||
const content = message.content.flatMap((item) => {
|
const content = partsOf(message.id).flatMap((item) => {
|
||||||
if (item.type === "text") return [item.text]
|
if (item.type === "text") return [item.text]
|
||||||
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
|
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
|
||||||
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
|
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ export function findMessageBoundary(input: {
|
||||||
direction: "next" | "prev"
|
direction: "next" | "prev"
|
||||||
children: readonly MessageChild[]
|
children: readonly MessageChild[]
|
||||||
messages: readonly SessionMessageInfo[]
|
messages: readonly SessionMessageInfo[]
|
||||||
|
hasText?: (messageID: string) => boolean
|
||||||
scrollTop: number
|
scrollTop: number
|
||||||
viewportY: number
|
viewportY: number
|
||||||
currentID?: string
|
currentID?: string
|
||||||
|
|
@ -35,7 +36,9 @@ export function findMessageBoundary(input: {
|
||||||
return [{ id: child.id, y, top: y }]
|
return [{ id: child.id, y, top: y }]
|
||||||
}
|
}
|
||||||
if (input.userOnly || message.type !== "assistant") return []
|
if (input.userOnly || message.type !== "assistant") return []
|
||||||
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
|
const hasText =
|
||||||
|
input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim())
|
||||||
|
if (!hasText) return []
|
||||||
const y = input.scrollTop + child.y - input.viewportY
|
const y = input.scrollTop + child.y - input.viewportY
|
||||||
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -146,9 +146,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
||||||
const input = createMemo(() => {
|
const input = createMemo(() => {
|
||||||
const tool = props.request.source
|
const tool = props.request.source
|
||||||
if (!tool) return {}
|
if (!tool) return {}
|
||||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
const part = data.session.message.parts(props.request.sessionID, tool.messageID).get(tool.callID)?.()
|
||||||
if (message?.type !== "assistant") return {}
|
|
||||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
|
||||||
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
|
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
|
||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
@ -167,7 +165,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<box paddingLeft={1} gap={1}>
|
<box paddingLeft={1} gap={1}>
|
||||||
<text fg={themeV2.text.subdued()}>This will allow the following patterns until OpenCode is restarted</text>
|
<text fg={themeV2.text.subdued()}>
|
||||||
|
This will allow the following patterns until OpenCode is restarted
|
||||||
|
</text>
|
||||||
<box>
|
<box>
|
||||||
<For each={props.request.save ?? []}>
|
<For each={props.request.save ?? []}>
|
||||||
{(pattern) => (
|
{(pattern) => (
|
||||||
|
|
@ -643,10 +643,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
|
||||||
...(props.escapeKey ? ["app.exit"] : []),
|
|
||||||
...(props.fullscreen ? ["permission.prompt.fullscreen"] : []),
|
|
||||||
],
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
||||||
|
|
@ -703,20 +700,14 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||||
<box
|
<box
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={themeV2.background.action(
|
backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
|
||||||
option === store.selected ? "focused" : "default",
|
|
||||||
)}
|
|
||||||
onMouseOver={() => setStore("selected", option)}
|
onMouseOver={() => setStore("selected", option)}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
setStore("selected", option)
|
setStore("selected", option)
|
||||||
props.onSelect(option)
|
props.onSelect(option)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text
|
<text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
|
||||||
fg={themeV2.text.action(
|
|
||||||
option === store.selected ? "focused" : "default",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.options[option]}
|
{props.options[option]}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
@ -726,7 +717,8 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||||
<Show when={props.fullscreen}>
|
<Show when={props.fullscreen}>
|
||||||
<text fg={themeV2.text()}>
|
<text fg={themeV2.text()}>
|
||||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
|
{shortcuts.get("permission.prompt.fullscreen")}{" "}
|
||||||
|
<span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={themeV2.text()}>
|
<text fg={themeV2.text()}>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,11 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
|
||||||
const messages = data.session.message.list(sessionID())
|
const messages = data.session.message.list(sessionID())
|
||||||
const inputs = new Set(data.session.input.list(sessionID()))
|
const inputs = new Set(data.session.input.list(sessionID()))
|
||||||
const boundary = revertBoundary()
|
const boundary = revertBoundary()
|
||||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
const rows = reduceSessionRows(
|
||||||
|
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||||
|
inputs,
|
||||||
|
(message) => data.session.message.parts(sessionID(), message.id).values(),
|
||||||
|
)
|
||||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||||
rows.splice(
|
rows.splice(
|
||||||
position === -1 ? rows.length : position,
|
position === -1 ? rows.length : position,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||||
import { Keyed, Layout, Transaction } from "effect-quark"
|
import { Keyed, Layout, Transaction } from "effect-quark"
|
||||||
|
import { SessionContent } from "./content"
|
||||||
|
|
||||||
const PartRefLayout = Layout.struct({
|
const PartRefLayout = Layout.struct({
|
||||||
messageID: Layout.string,
|
messageID: Layout.string,
|
||||||
|
|
@ -180,7 +181,11 @@ export namespace SessionTimeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
export function reduceSessionRows(
|
||||||
|
messages: SessionMessageInfo[],
|
||||||
|
inputs = new Set<string>(),
|
||||||
|
partsOf?: (message: SessionMessageAssistant & { id: string }) => readonly SessionContent.Part[],
|
||||||
|
) {
|
||||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||||
|
|
@ -195,11 +200,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||||
rows.push(messageRow(message.id))
|
rows.push(messageRow(message.id))
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
const ordinals = { text: 0, reasoning: 0 }
|
const parts = partsOf ? partsOf(message) : SessionContent.withPartIDs(message.content)
|
||||||
message.content.forEach((part) => {
|
parts.forEach((part) => {
|
||||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
|
||||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||||
append(rows, { messageID: message.id, partID }, part)
|
append(rows, { messageID: message.id, partID: part.partID }, part)
|
||||||
})
|
})
|
||||||
if (isTerminalFinish(message.finish) || message.error || message.retry) {
|
if (isTerminalFinish(message.finish) || message.error || message.retry) {
|
||||||
completePrevious(rows)
|
completePrevious(rows)
|
||||||
|
|
@ -239,15 +243,6 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
|
||||||
if (message?.type === "assistant") return message.id
|
if (message?.type === "assistant") return message.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
|
||||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
|
||||||
if (tool) return tool
|
|
||||||
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
|
||||||
if (!match) return
|
|
||||||
const ordinal = Number(match[2])
|
|
||||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTerminalFinish(finish: string | undefined) {
|
export function isTerminalFinish(finish: string | undefined) {
|
||||||
return !!finish && !["tool-calls", "unknown"].includes(finish)
|
return !!finish && !["tool-calls", "unknown"].includes(finish)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -883,6 +883,96 @@ test("classifies live tool rows independently of their call ID", async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("publishes streaming deltas to one part slot without touching siblings", async () => {
|
||||||
|
const events = createEventStream()
|
||||||
|
const sessionID = "session-part-slots"
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||||
|
}, events)
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let client!: ReturnType<typeof useClient>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
client = useClient()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<ClientProvider api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</ClientProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => client.connection.status() === "connected")
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_slots_step",
|
||||||
|
created: 1,
|
||||||
|
type: "session.step.started",
|
||||||
|
durable: durable(sessionID),
|
||||||
|
data: {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: "message-assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_slots_tool",
|
||||||
|
created: 2,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: durable(sessionID, 1),
|
||||||
|
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-slots", name: "read" },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_slots_text",
|
||||||
|
created: 3,
|
||||||
|
type: "session.text.started",
|
||||||
|
durable: durable(sessionID, 2),
|
||||||
|
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
|
||||||
|
})
|
||||||
|
const parts = data.session.message.parts(sessionID, "message-assistant")
|
||||||
|
await wait(() => parts.get("text:0") !== undefined && parts.get("call-slots") !== undefined)
|
||||||
|
|
||||||
|
const publications = { text: 0, tool: 0, structure: 0 }
|
||||||
|
const disposers = [
|
||||||
|
parts.get("text:0")!.subscribe(() => publications.text++),
|
||||||
|
parts.get("call-slots")!.subscribe(() => publications.tool++),
|
||||||
|
parts.slots.subscribe(() => publications.structure++),
|
||||||
|
]
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_slots_delta_1",
|
||||||
|
created: 4,
|
||||||
|
type: "session.text.delta",
|
||||||
|
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
|
||||||
|
})
|
||||||
|
emitEvent(events, {
|
||||||
|
id: "evt_slots_delta_2",
|
||||||
|
created: 5,
|
||||||
|
type: "session.text.delta",
|
||||||
|
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
|
||||||
|
})
|
||||||
|
await wait(() => {
|
||||||
|
const part = parts.get("text:0")?.()
|
||||||
|
return part?.type === "text" && part.text === "onetwo"
|
||||||
|
})
|
||||||
|
|
||||||
|
// One slot publication per delta; the sibling tool slot and the part
|
||||||
|
// structure never publish, independent of message size.
|
||||||
|
expect(publications).toEqual({ text: 2, tool: 0, structure: 0 })
|
||||||
|
disposers.forEach((dispose) => dispose())
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("does not publish timeline rows for duplicate streaming deltas", async () => {
|
test("does not publish timeline rows for duplicate streaming deltas", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const sessionID = "session-stream-metrics"
|
const sessionID = "session-stream-metrics"
|
||||||
|
|
@ -950,10 +1040,8 @@ test("does not publish timeline rows for duplicate streaming deltas", async () =
|
||||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
|
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
|
||||||
})
|
})
|
||||||
await wait(() => {
|
await wait(() => {
|
||||||
const message = data.session.message.get(sessionID, "message-assistant")
|
const part = data.session.message.parts(sessionID, "message-assistant").get("text:0")?.()
|
||||||
return (
|
return part?.type === "text" && part.text === "onetwo"
|
||||||
message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo")
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 })
|
expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 })
|
||||||
|
|
@ -2482,12 +2570,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
await wait(() => {
|
await wait(() => {
|
||||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||||
return (
|
return (
|
||||||
assistant?.type === "assistant" &&
|
part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child"
|
||||||
assistant.content[0]?.type === "tool" &&
|
|
||||||
assistant.content[0].state.status === "running" &&
|
|
||||||
assistant.content[0].state.structured.sessionID === "session-child"
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -2507,19 +2592,15 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
await wait(() => {
|
await wait(() => {
|
||||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||||
return (
|
return part?.type === "tool" && part.state.status === "error"
|
||||||
assistant?.type === "assistant" &&
|
|
||||||
assistant.content[0]?.type === "tool" &&
|
|
||||||
assistant.content[0].state.status === "error"
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||||
expect(assistant?.type).toBe("assistant")
|
expect(assistant?.type).toBe("assistant")
|
||||||
if (assistant?.type !== "assistant") return
|
if (assistant?.type !== "assistant") return
|
||||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||||
const tool = assistant.content[0]
|
const tool = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||||
expect(tool?.type).toBe("tool")
|
expect(tool?.type).toBe("tool")
|
||||||
if (tool?.type !== "tool") return
|
if (tool?.type !== "tool") return
|
||||||
expect(tool.state.status).toBe("error")
|
expect(tool.state.status).toBe("error")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue