feat(core): finalize session event lifecycle (#35272)
This commit is contained in:
parent
b046bbe4e3
commit
bf01264661
82 changed files with 5200 additions and 3711 deletions
|
|
@ -54,6 +54,7 @@ type Data = {
|
|||
// session ID in that family, including the key itself once its info arrives.
|
||||
family: Record<string, string[]>
|
||||
status: Record<string, DataSessionStatus>
|
||||
compaction: Partial<Record<string, string>>
|
||||
message: Record<string, SessionMessage[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
|
|
@ -90,6 +91,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
compaction: {},
|
||||
message: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
|
|
@ -149,14 +151,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||
)
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -273,7 +273,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.prompt.promoted": {
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
|
|
@ -335,7 +334,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
break
|
||||
case "session.shell.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
|
|
@ -346,7 +344,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
break
|
||||
case "session.shell.ended":
|
||||
setSessionStatus(event.data.sessionID, "idle")
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.shell(draft, event.data.shell.id)
|
||||
if (!match) return
|
||||
|
|
@ -356,11 +353,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
break
|
||||
case "session.step.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
if (index.has(event.data.assistantMessageID)) return
|
||||
const position = index.get(event.data.assistantMessageID)
|
||||
const existing = position === undefined ? undefined : draft[position]
|
||||
if (existing?.type === "assistant") {
|
||||
existing.agent = event.data.agent
|
||||
existing.model = event.data.model
|
||||
existing.retry = undefined
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
}
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.created
|
||||
if (currentAssistant) {
|
||||
currentAssistant.retry = undefined
|
||||
currentAssistant.time.completed = event.created
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
|
|
@ -373,7 +383,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
break
|
||||
case "session.step.ended":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
|
|
@ -392,32 +401,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.data.textID,
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.textID,
|
||||
)
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.textID,
|
||||
)
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
break
|
||||
|
|
@ -458,7 +461,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.provider = event.data.provider
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||
})
|
||||
break
|
||||
|
|
@ -487,11 +491,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
|
|
@ -510,11 +511,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.provider = {
|
||||
executed: event.data.provider.executed || match.provider?.executed === true,
|
||||
metadata: match.provider?.metadata,
|
||||
resultMetadata: event.data.provider.metadata,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
|
|
@ -522,41 +520,55 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.data.reasoningID,
|
||||
text: "",
|
||||
providerMetadata: event.data.providerMetadata,
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.reasoningID,
|
||||
)
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.reasoningID,
|
||||
)
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.retried":
|
||||
case "session.compaction.started":
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: event.data.at,
|
||||
error: event.data.error,
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.execution.started":
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
break
|
||||
case "session.execution.settled":
|
||||
case "session.compaction.started":
|
||||
setStore("session", "compaction", event.data.sessionID, "")
|
||||
break
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted":
|
||||
setSessionStatus(event.data.sessionID, "idle")
|
||||
if (store.session.compaction[event.data.sessionID] !== undefined)
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
break
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
|
|
@ -573,17 +585,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.messageID),
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.messageID)
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
})
|
||||
break
|
||||
case "session.compaction.delta":
|
||||
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
|
|
@ -710,6 +724,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||
},
|
||||
},
|
||||
compaction(sessionID: string) {
|
||||
return store.session.compaction[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||
registerSession(sessionID)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ function sessionErrorMessage(error: SessionError) {
|
|||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const active = new Set<string>()
|
||||
const errored = new Set<string>()
|
||||
const terminal = new Set<string>()
|
||||
const forms = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
|
@ -73,14 +73,13 @@ const tui: TuiPlugin = async (api) => {
|
|||
})
|
||||
|
||||
const started = (sessionID: string) => {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
terminal.delete(sessionID)
|
||||
}
|
||||
|
||||
const ended = (sessionID: string) => {
|
||||
if (!active.has(sessionID)) return
|
||||
active.delete(sessionID)
|
||||
|
||||
if (terminal.has(sessionID)) return
|
||||
terminal.add(sessionID)
|
||||
if (errored.has(sessionID)) {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
|
|
@ -90,28 +89,25 @@ const tui: TuiPlugin = async (api) => {
|
|||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
}
|
||||
|
||||
api.event.on("session.prompt.promoted", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.shell.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.step.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.retried", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.compaction.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.shell.ended", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.step.ended", (event) => {
|
||||
if (event.data.finish === "tool-calls") return
|
||||
ended(event.data.sessionID)
|
||||
})
|
||||
api.event.on("session.step.failed", (event) => {
|
||||
api.event.on("session.execution.started", (event) => started(event.data.sessionID))
|
||||
api.event.on("session.execution.succeeded", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.interrupted", (event) => ended(event.data.sessionID))
|
||||
api.event.on("session.execution.failed", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!active.has(sessionID)) return
|
||||
if (errored.has(sessionID)) {
|
||||
ended(sessionID)
|
||||
return
|
||||
}
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, "Session error", "error")
|
||||
notify(api, sessionID, event.data.error.message, "error")
|
||||
ended(sessionID)
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.data.sessionID
|
||||
if (!sessionID) return
|
||||
if (!active.has(sessionID)) return
|
||||
if (api.state.session.status(sessionID)?.type !== "busy") return
|
||||
if (errored.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.data.error), "error")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
|||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { LocationProvider } from "../../context/location"
|
||||
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
||||
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
|
@ -914,6 +914,9 @@ export function Session() {
|
|||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={data.session.compaction(route.sessionID)}>
|
||||
{(text) => <CompactionMessage text={text()} />}
|
||||
</Show>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
|
|
@ -1096,7 +1099,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
|||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (item?.type !== "assistant") return
|
||||
return item.content.find((part) => part.id === props.partRef.partID)
|
||||
return resolvePart(item, props.partRef.partID)
|
||||
})
|
||||
return (
|
||||
<Show when={part()}>
|
||||
|
|
@ -1136,7 +1139,7 @@ function SessionGroupView(props: {
|
|||
refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = message.content.find((part) => part.id === ref.partID)
|
||||
const part = resolvePart(message, ref.partID)
|
||||
if (part?.type !== "tool") return []
|
||||
return [part]
|
||||
})
|
||||
|
|
@ -1216,6 +1219,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
|||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<box paddingLeft={3} marginTop={props.message.error && !interrupted() ? 1 : 0}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.textMuted : local.agent.color(props.message.agent) }}>
|
||||
|
|
@ -1269,9 +1273,15 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
|
|||
)
|
||||
}
|
||||
|
||||
function CompactionMessage() {
|
||||
function CompactionMessage(props: { text?: string }) {
|
||||
const { theme } = useTheme()
|
||||
return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} />
|
||||
return (
|
||||
<box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive}>
|
||||
<Show when={props.text}>
|
||||
<text fg={theme.textMuted}>{props.text}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(status: "added" | "modified" | "deleted") {
|
||||
|
|
@ -1547,6 +1557,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
|||
<text fg={theme.textMuted}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
|
|
@ -1566,6 +1577,21 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
|||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.retry}>
|
||||
{(retry) => (
|
||||
<box paddingLeft={3} marginTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Retry attempt {retry().attempt} scheduled: {retry().error.message} [{retry().error.type}]
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
|
|
|
|||
|
|
@ -74,19 +74,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.message
|
||||
.list(sessionID())
|
||||
.flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: data.session.input.has(sessionID(), message.id),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
data.session.message.list(sessionID()).flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: data.session.input.has(sessionID(), message.id),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
),
|
||||
)
|
||||
|
|
@ -138,6 +136,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
}),
|
||||
)
|
||||
|
||||
const removeFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
|
||||
if (index !== -1) draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
|
||||
const isQueued = (messageID: string) => {
|
||||
return data.session.input.has(sessionID(), messageID)
|
||||
}
|
||||
|
|
@ -166,24 +172,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
data.on("session.compaction.ended", message),
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||
}),
|
||||
data.on("session.text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.textID })
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||
}),
|
||||
data.on("session.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
|
||||
}),
|
||||
data.on("session.reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.reasoningID })
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` })
|
||||
}),
|
||||
data.on("session.tool.input.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: event.data.callID }, event.data.name)
|
||||
}),
|
||||
data.on("session.retry.scheduled", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.started", (event) => {
|
||||
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
|
|
@ -207,11 +219,13 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<s
|
|||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.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
|
||||
append(rows, { messageID: message.id, partID: part.id }, part)
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error) {
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
|
|
@ -221,6 +235,15 @@ export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<s
|
|||
)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||
if (part.type === "tool") {
|
||||
if (exploration(part.name)) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ async function setup() {
|
|||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
status: () => ({ type: "busy" }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
|
@ -96,46 +97,34 @@ function durable(sessionID: string) {
|
|||
return { aggregateID: sessionID, seq: 0, version: 1 }
|
||||
}
|
||||
|
||||
function stepStarted(id: string, sessionID = "session"): V2Event {
|
||||
function executionStarted(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
type: "session.execution.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
data: { sessionID },
|
||||
}
|
||||
}
|
||||
|
||||
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
|
||||
function executionSucceeded(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.ended",
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
finish,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
data: { sessionID },
|
||||
}
|
||||
}
|
||||
|
||||
function stepFailed(id: string, sessionID = "session"): V2Event {
|
||||
function executionFailed(id: string, sessionID = "session"): V2Event {
|
||||
return {
|
||||
id,
|
||||
created: 0,
|
||||
type: "session.step.failed",
|
||||
type: "session.execution.failed",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: `msg_${id}`,
|
||||
error: { type: "unknown", message: "boom" },
|
||||
},
|
||||
}
|
||||
|
|
@ -224,12 +213,12 @@ describe("internal notifications TUI plugin", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
test("notifies for terminal lifecycle events even when attached after execution started", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepEnded("event-1"))
|
||||
harness.emit(stepStarted("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
harness.emit(executionSucceeded("event-1"))
|
||||
harness.emit(executionStarted("event-2"))
|
||||
harness.emit(executionSucceeded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
|
|
@ -238,6 +227,12 @@ describe("internal notifications TUI plugin", () => {
|
|||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -250,8 +245,8 @@ describe("internal notifications TUI plugin", () => {
|
|||
type: "form.created",
|
||||
data: { form: form("form-1", "subagent") },
|
||||
})
|
||||
harness.emit(stepStarted("event-2", "subagent"))
|
||||
harness.emit(stepEnded("event-3", "subagent"))
|
||||
harness.emit(executionStarted("event-2", "subagent"))
|
||||
harness.emit(executionSucceeded("event-3", "subagent"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
|
|
@ -272,14 +267,14 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepStarted("event-1"))
|
||||
harness.emit(stepFailed("event-2"))
|
||||
harness.emit(stepEnded("event-3"))
|
||||
harness.emit(executionStarted("event-1"))
|
||||
harness.emit(executionFailed("event-2"))
|
||||
harness.emit(executionSucceeded("event-3"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session error",
|
||||
message: "boom",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
|
|
@ -289,20 +284,21 @@ describe("internal notifications TUI plugin", () => {
|
|||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit(stepStarted("event-1", "abort"))
|
||||
harness.emit(executionStarted("event-1", "abort"))
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit(stepStarted("event-3", "timeout"))
|
||||
harness.emit(executionStarted("event-3", "timeout"))
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
created: 0,
|
||||
type: "session.error",
|
||||
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
harness.emit(executionFailed("event-5", "timeout"))
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { onMount } from "solid-js"
|
|||
import { ProjectProvider } from "../../../src/context/project"
|
||||
import { SDKProvider } from "../../../src/context/sdk"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { createSessionRows } from "../../../src/routes/session/rows"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
|
|
@ -177,16 +177,11 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
|
||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started_after_reconnect",
|
||||
id: "evt_execution_started_after_reconnect",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-new"),
|
||||
data: {
|
||||
sessionID: "session-new",
|
||||
assistantMessageID: "message-new",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
data: { sessionID: "session-new" },
|
||||
})
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
resolveActive(json({ data: {} }))
|
||||
|
|
@ -326,7 +321,7 @@ test("removes committed revert messages from local state", async () => {
|
|||
created: 3,
|
||||
type: "session.revert.committed",
|
||||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, messageID: "msg_002" },
|
||||
data: { sessionID, to: "msg_002" },
|
||||
})
|
||||
|
||||
await wait(() => data.session.message.ids(sessionID).length === 1)
|
||||
|
|
@ -406,9 +401,11 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
rows = createSessionRows(() => "session-retry")
|
||||
return <box />
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +425,15 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
await wait(() => data.session.status("session-active") === "running")
|
||||
expect(data.session.status("session-idle")).toBe("idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-live"),
|
||||
data: { sessionID: "session-live" },
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
created: 0,
|
||||
|
|
@ -440,8 +446,6 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_ended",
|
||||
created: 0,
|
||||
|
|
@ -462,16 +466,23 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
expect(data.session.status("session-live")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_settled",
|
||||
id: "evt_execution_succeeded",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
outcome: "success",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("session-live", 1, 3),
|
||||
data: { sessionID: "session-live" },
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-failed"),
|
||||
data: { sessionID: "session-failed" },
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_step_started",
|
||||
created: 0,
|
||||
|
|
@ -484,8 +495,6 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_failed",
|
||||
created: 0,
|
||||
|
|
@ -494,26 +503,146 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-failed", "message-failed")
|
||||
return assistant?.type === "assistant" && assistant.finish === "error"
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.finish === "error" &&
|
||||
assistant.error?.type === "provider.content-filter"
|
||||
)
|
||||
})
|
||||
expect(data.session.status("session-failed")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_settled",
|
||||
id: "evt_failed_execution_failed",
|
||||
created: 0,
|
||||
type: "session.execution.settled",
|
||||
type: "session.execution.failed",
|
||||
durable: durable("session-failed", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
outcome: "failure",
|
||||
error: { type: "unknown", message: "Provider unavailable" },
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.status("session-failed") === "idle")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_execution_started",
|
||||
created: 0,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-retry"),
|
||||
data: { sessionID: "session-retry" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_step_started",
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 2),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled",
|
||||
created: 0,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 3),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_next_step",
|
||||
created: 2_000,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 4),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry === undefined
|
||||
})
|
||||
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled_again",
|
||||
created: 2_000,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 5),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
attempt: 3,
|
||||
at: 6_000,
|
||||
error: { type: "provider.transport", message: "Disconnected again" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 3
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_interrupted",
|
||||
created: 2_000,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("session-retry", 1, 6),
|
||||
data: { sessionID: "session-retry", reason: "shutdown" },
|
||||
})
|
||||
await wait(() => data.session.status("session-retry") === "idle")
|
||||
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
created: 0,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-live", 2),
|
||||
data: { sessionID: "session-live", reason: "auto" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_1",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "Live " },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_2",
|
||||
created: 0,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "summary" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === "Live summary")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
created: 0,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-live", 3),
|
||||
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === undefined)
|
||||
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
|
||||
type: "compaction",
|
||||
summary: "Live summary",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -1020,9 +1149,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
input: {},
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
executed: false,
|
||||
state: { call: true },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
|
|
@ -1035,7 +1164,8 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
assistantMessageID: "msg_explicit_assistant_9",
|
||||
callID: "call-1",
|
||||
error: { type: "unknown", message: "aborted" },
|
||||
provider: { executed: false, metadata: { fake: { result: true } } },
|
||||
executed: false,
|
||||
resultState: { result: true },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1061,11 +1191,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
expect(tool.state.input).toEqual({})
|
||||
expect(tool.state.structured).toEqual({})
|
||||
expect(tool.state.content).toEqual([])
|
||||
expect(tool.provider).toEqual({
|
||||
executed: false,
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(tool.executed).toBe(false)
|
||||
expect(tool.providerState).toEqual({ call: true })
|
||||
expect(tool.providerResultState).toEqual({ result: true })
|
||||
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
|||
const messages: SessionMessage[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", id: "text-1", text: "Looking" },
|
||||
{ type: "text", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||
{ type: "text", id: "text-2", text: "Done" },
|
||||
{ type: "text", text: "Done" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
|
|
@ -30,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
|||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -62,20 +62,38 @@ test("keeps non-exploration tools as individual part rows", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("assigns stable kind ordinals within an assistant message", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
{ type: "text", text: "Second" },
|
||||
{ type: "reasoning", text: "Check" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
|
||||
{ type: "reasoning", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "reasoning", id: "reasoning-2", text: "" },
|
||||
{ type: "reasoning", text: "" },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
|
|
@ -95,9 +113,7 @@ test("completes exploration groups when another row follows", () => {
|
|||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
]),
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
]
|
||||
|
|
@ -182,6 +198,17 @@ test("renders synthetic messages with descriptions", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
const message = assistant("assistant-retry", [])
|
||||
message.retry = {
|
||||
attempt: 2,
|
||||
at: 2_000,
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
}
|
||||
|
||||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue