Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts: # packages/client/test/promise.test.ts # packages/core/schema.json # packages/core/src/database/migration.gen.ts # packages/core/src/tool/websearch.ts # packages/sdk-next/src/index.ts # packages/sdk/js/src/v2/gen/types.gen.ts
This commit is contained in:
commit
7b8d8b8861
666 changed files with 46671 additions and 20220 deletions
|
|
@ -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"
|
||||
|
||||
|
|
@ -108,6 +108,64 @@ test("refreshes resources into reactive getters", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("restores running manual compaction before applying live deltas", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-compaction/message")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "message-compaction",
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "Existing ",
|
||||
recent: "",
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.message.refresh("session-compaction")
|
||||
expect(data.session.compaction("session-compaction")).toBe("Existing ")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta",
|
||||
created: 2,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-compaction", text: "summary" },
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-compaction", "message-compaction")
|
||||
return message?.type === "compaction" && message.summary === "Existing summary"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
const events = createEventStream()
|
||||
const requests = { active: 0, event: 0, model: 0 }
|
||||
|
|
@ -119,7 +177,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
}
|
||||
if (url.pathname === "/api/session/active") {
|
||||
requests.active++
|
||||
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } }, watermarks: {} })
|
||||
if (requests.active === 1) return json({ data: { "session-stale": { type: "running" } } })
|
||||
return new Promise<Response>((resolve) => {
|
||||
resolveActive = resolve
|
||||
})
|
||||
|
|
@ -177,19 +235,14 @@ 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: {}, watermarks: {} }))
|
||||
resolveActive(json({ data: {} }))
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
|
|
@ -284,6 +337,60 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("removes committed revert messages from local state", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-revert"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
for (const [seq, inputID] of ["msg_001", "msg_002", "msg_003"].entries()) {
|
||||
emitEvent(events, {
|
||||
id: EventV2.ID.create(),
|
||||
created: seq,
|
||||
type: "session.prompt.admitted",
|
||||
durable: durable(sessionID, seq),
|
||||
data: { sessionID, inputID, prompt: { text: inputID }, delivery: "steer" },
|
||||
})
|
||||
}
|
||||
await wait(() => data.session.message.ids(sessionID).length === 3)
|
||||
|
||||
emitEvent(events, {
|
||||
id: EventV2.ID.create(),
|
||||
created: 3,
|
||||
type: "session.revert.committed",
|
||||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, to: "msg_002" },
|
||||
})
|
||||
|
||||
await wait(() => data.session.message.ids(sessionID).length === 1)
|
||||
expect(data.session.message.ids(sessionID)).toEqual(["msg_001"])
|
||||
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
|
||||
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("connectedOnce is false until first connect and persists across disconnect", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
let stream: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
|
|
@ -349,13 +456,16 @@ test("connectedOnce is false until first connect and persists across disconnect"
|
|||
test("tracks session status from active sessions and execution events", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
let manualRows!: SessionRow[]
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
rows = createSessionRows(() => "session-retry")
|
||||
manualRows = createSessionRows(() => "session-manual")
|
||||
return <box />
|
||||
}
|
||||
|
||||
|
|
@ -375,6 +485,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,
|
||||
|
|
@ -387,8 +506,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,
|
||||
|
|
@ -409,16 +526,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,
|
||||
|
|
@ -431,8 +555,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,
|
||||
|
|
@ -441,26 +563,189 @@ 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_admitted",
|
||||
created: 0,
|
||||
type: "session.compaction.admitted",
|
||||
durable: durable("session-manual", 1),
|
||||
data: { sessionID: "session-manual", inputID: "message-compaction" },
|
||||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.status === "queued"
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_started",
|
||||
created: 1,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-manual", 2),
|
||||
data: { sessionID: "session-manual", reason: "manual" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_delta",
|
||||
created: 2,
|
||||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-manual", text: "Streamed summary" },
|
||||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.summary === "Streamed summary"
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_ended",
|
||||
created: 3,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-manual", 3),
|
||||
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.status === "completed"
|
||||
})
|
||||
expect(manualRows.filter((row) => row.type === "message")).toEqual([
|
||||
{ type: "message", messageID: "message-compaction" },
|
||||
])
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -967,9 +1252,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, {
|
||||
|
|
@ -982,7 +1267,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 },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -1008,11 +1294,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",
|
||||
|
|
@ -1028,7 +1312,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("renders admitted prompts immediately with queued marker and clears when promoted", async () => {
|
||||
test("renders admitted prompts immediately and tracks them until promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_user_1"
|
||||
|
|
@ -1081,10 +1365,12 @@ test("renders admitted prompts immediately with queued marker and clears when pr
|
|||
})
|
||||
await wait(() => sync.session.message.list(sessionID)?.length === 1)
|
||||
const admitted = sync.session.message.list(sessionID)?.[0]
|
||||
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello", metadata: { queued: true } })
|
||||
expect(admitted).toMatchObject({ id: messageID, type: "user", text: "hello" })
|
||||
expect(admitted?.metadata).toBeUndefined()
|
||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||
|
||||
await sync.session.message.refresh(sessionID)
|
||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata?.queued).toBeUndefined()
|
||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompted_1",
|
||||
|
|
@ -1104,7 +1390,8 @@ test("renders admitted prompts immediately with queued marker and clears when pr
|
|||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: messageID, text: "hello" })
|
||||
expect(message.metadata?.queued).toBeUndefined()
|
||||
expect(message.metadata).toBeUndefined()
|
||||
expect(sync.session.input.list(sessionID)).toEqual([])
|
||||
expect(sync.session.message.ids(sessionID)).toEqual([messageID])
|
||||
expect(sync.session.message.ids("missing")).toEqual([])
|
||||
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
|
||||
|
|
@ -1115,7 +1402,7 @@ test("renders admitted prompts immediately with queued marker and clears when pr
|
|||
}
|
||||
})
|
||||
|
||||
test("projects live context updates with their message ID", async () => {
|
||||
test("projects live instruction updates with their message ID", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
|
|
@ -1145,21 +1432,21 @@ test("projects live context updates with their message ID", async () => {
|
|||
try {
|
||||
await mounted
|
||||
emitEvent(events, {
|
||||
id: "evt_context_1",
|
||||
id: "evt_instructions_1",
|
||||
created: 0,
|
||||
type: "session.context.updated",
|
||||
type: "session.instructions.updated",
|
||||
durable: durable("session-1"),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
text: "Updated context",
|
||||
text: "Updated instructions",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
|
||||
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_instructions_1")),
|
||||
type: "system",
|
||||
text: "Updated context",
|
||||
text: "Updated instructions",
|
||||
time: { created: 0 },
|
||||
})
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,19 @@ function FailedCompleteToolFixture() {
|
|||
)
|
||||
}
|
||||
|
||||
function ReminderAlignmentFixture() {
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box paddingLeft={3}>
|
||||
<text>Switched variant to medium</text>
|
||||
</box>
|
||||
<InlineToolRow icon="◈" complete={true} pending="Notice">
|
||||
Instructions updated
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
||||
testSetup = await testRender(component, options)
|
||||
await testSetup.renderOnce()
|
||||
|
|
@ -124,6 +137,12 @@ describe("TUI inline tool wrapping", () => {
|
|||
expect(frame).not.toContain("Read failed")
|
||||
})
|
||||
|
||||
test("aligns switch reminders with instruction reminders", async () => {
|
||||
expect(await renderFrame(() => <ReminderAlignmentFixture />, { width: 35, height: 2 })).toBe(
|
||||
" Switched variant to medium\n ◈ Instructions updated",
|
||||
)
|
||||
})
|
||||
|
||||
test("filters malformed nested tool wire data", () => {
|
||||
expect(
|
||||
parseApplyPatchFiles([
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { describe, expect, test } from "bun:test"
|
|||
//
|
||||
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
||||
// Enter, or the input's native onSubmit racing another dispatch) each
|
||||
// passed the `if (!store.prompt.input) return false` guard, each
|
||||
// passed the `if (!store.prompt.text) return false` guard, each
|
||||
// `await sdk.client.session.create(...)`, and each only captured
|
||||
// `inputText = store.prompt.input` AFTER that await. The first invocation
|
||||
// `inputText = store.prompt.text` AFTER that await. The first invocation
|
||||
// finished, sent the prompt, and cleared the store; the second invocation,
|
||||
// now past its await, read the cleared store and sent an empty prompt to a
|
||||
// second freshly-created session - leaving an orphaned session with the
|
||||
|
|
|
|||
|
|
@ -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,45 @@ 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" }])
|
||||
})
|
||||
|
||||
test("places a pending compaction barrier before every queued user message", () => {
|
||||
const queued = (id: string, text: string, created: number): SessionMessage => ({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
const messages: SessionMessage[] = [
|
||||
queued("user-before", "Before", 1),
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction",
|
||||
status: "queued",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 2 },
|
||||
},
|
||||
queued("user-after", "After", 3),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
{ type: "message", messageID: "compaction" },
|
||||
{ type: "message", messageID: "user-before" },
|
||||
{ type: "message", messageID: "user-after" },
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue