|
|
@@ -67,6 +67,22 @@ function idle(sessionID = "session-1") {
|
|
|
} satisfies SdkEvent
|
|
|
}
|
|
|
|
|
|
+function retry(sessionID: string, attempt: number, message: string) {
|
|
|
+ return {
|
|
|
+ id: `evt-${sessionID}-retry-${attempt}`,
|
|
|
+ type: "session.status",
|
|
|
+ properties: {
|
|
|
+ sessionID,
|
|
|
+ status: {
|
|
|
+ type: "retry",
|
|
|
+ attempt,
|
|
|
+ message,
|
|
|
+ next: 1,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ } satisfies SdkEvent
|
|
|
+}
|
|
|
+
|
|
|
function assistant(id: string) {
|
|
|
return {
|
|
|
id: `evt-${id}`,
|
|
|
@@ -290,12 +306,12 @@ function toolUpdated(part: SessionToolPart): SdkEvent {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function textDelta(messageID: string, partID: string, delta: string): SdkEvent {
|
|
|
+function textDelta(messageID: string, partID: string, delta: string, sessionID = "session-1"): SdkEvent {
|
|
|
return {
|
|
|
id: `evt-${partID}-delta`,
|
|
|
type: "message.part.delta",
|
|
|
properties: {
|
|
|
- sessionID: "session-1",
|
|
|
+ sessionID,
|
|
|
messageID,
|
|
|
partID,
|
|
|
field: "text",
|
|
|
@@ -331,6 +347,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|
|
const commits: StreamCommit[] = []
|
|
|
const events: FooterEvent[] = []
|
|
|
let closed = false
|
|
|
+ let idleCalls = 0
|
|
|
|
|
|
const api: FooterApi = {
|
|
|
get isClosed() {
|
|
|
@@ -346,6 +363,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|
|
fn?.(next)
|
|
|
},
|
|
|
idle() {
|
|
|
+ idleCalls += 1
|
|
|
return Promise.resolve()
|
|
|
},
|
|
|
close() {
|
|
|
@@ -356,7 +374,7 @@ function footer(fn?: (commit: StreamCommit) => void) {
|
|
|
},
|
|
|
}
|
|
|
|
|
|
- return { api, commits, events }
|
|
|
+ return { api, commits, events, get idleCalls() { return idleCalls } }
|
|
|
}
|
|
|
|
|
|
function sdk(
|
|
|
@@ -398,6 +416,355 @@ function sdk(
|
|
|
}
|
|
|
|
|
|
describe("run stream transport", () => {
|
|
|
+ test("does not replay persisted main-session history during bootstrap by default", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const transport = await createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) =>
|
|
|
+ sessionID === "session-1"
|
|
|
+ ? ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ {
|
|
|
+ ...textPart("text-1", "msg-1", "Hello."),
|
|
|
+ time: {
|
|
|
+ start: 1,
|
|
|
+ end: 2,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ : ok([]),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ expect(ui.commits).toEqual([])
|
|
|
+ expect(ui.idleCalls).toBe(0)
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("replays persisted main-session history during bootstrap when enabled", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const transport = await createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) =>
|
|
|
+ sessionID === "session-1"
|
|
|
+ ? ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ {
|
|
|
+ ...textPart("text-1", "msg-1", "Hello."),
|
|
|
+ time: {
|
|
|
+ start: 1,
|
|
|
+ end: 2,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ : ok([]),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ replay: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello."))
|
|
|
+ expect(ui.idleCalls).toBeGreaterThan(0)
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("caps replayed bootstrap history to the configured number of messages", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const transport = await createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) =>
|
|
|
+ ok(
|
|
|
+ sessionID === "session-1"
|
|
|
+ ? [
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ {
|
|
|
+ ...textPart("text-1", "msg-1", "Hello."),
|
|
|
+ time: {
|
|
|
+ start: 1,
|
|
|
+ end: 2,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-2",
|
|
|
+ parts: [
|
|
|
+ {
|
|
|
+ ...textPart("text-2", "msg-2", "World."),
|
|
|
+ time: {
|
|
|
+ start: 3,
|
|
|
+ end: 4,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ]
|
|
|
+ : [],
|
|
|
+ ),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ replay: true,
|
|
|
+ replayLimit: 1,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
|
|
+ expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
|
|
+ expect.objectContaining({
|
|
|
+ text: "World.",
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("skips buffered pre-bootstrap deltas already covered by replay history", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const gate = defer<void>()
|
|
|
+ let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
|
|
+ const task = createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) => {
|
|
|
+ if (sessionID !== "session-1") {
|
|
|
+ return ok([])
|
|
|
+ }
|
|
|
+
|
|
|
+ await gate.promise
|
|
|
+ return ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [textPart("text-1", "msg-1", "Hello")],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ replay: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await Promise.resolve()
|
|
|
+ src.push(textDelta("msg-1", "text-1", "lo"))
|
|
|
+ gate.resolve()
|
|
|
+ transport = await task
|
|
|
+
|
|
|
+ await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
|
|
+ await Bun.sleep(20)
|
|
|
+ expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
|
|
+ expect.objectContaining({
|
|
|
+ text: "Hello",
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport?.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("applies buffered pre-bootstrap deltas not yet persisted", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const gate = defer<void>()
|
|
|
+ let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
|
|
+ const task = createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) => {
|
|
|
+ if (sessionID !== "session-1") {
|
|
|
+ return ok([])
|
|
|
+ }
|
|
|
+
|
|
|
+ await gate.promise
|
|
|
+ return ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [textPart("text-1", "msg-1", "")],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ replay: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await Promise.resolve()
|
|
|
+ src.push(textDelta("msg-1", "text-1", "Hello"))
|
|
|
+ gate.resolve()
|
|
|
+ transport = await task
|
|
|
+
|
|
|
+ await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined))
|
|
|
+ await Bun.sleep(20)
|
|
|
+ expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([
|
|
|
+ expect.objectContaining({
|
|
|
+ text: "Hello",
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport?.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("preserves running footer state for resumed active sessions", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const transport = await createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) =>
|
|
|
+ sessionID === "session-1"
|
|
|
+ ? ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ runningTool({
|
|
|
+ sessionID: "session-1",
|
|
|
+ messageID: "msg-1",
|
|
|
+ id: "bash-1",
|
|
|
+ callID: "call-1",
|
|
|
+ tool: "bash",
|
|
|
+ body: {
|
|
|
+ command: "pwd",
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ : ok([]),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ replay: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const patch = await waitFor(() => {
|
|
|
+ const item = ui.events.findLast((event) => event.type === "stream.patch")
|
|
|
+ return item?.type === "stream.patch" ? item.patch : undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(patch).toEqual(
|
|
|
+ expect.objectContaining({
|
|
|
+ phase: "running",
|
|
|
+ status: "running bash",
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ test("drops completed historical subagent tabs during bootstrap", async () => {
|
|
|
+ const src = eventFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const transport = await createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ stream: src.stream,
|
|
|
+ messages: async ({ sessionID }) => {
|
|
|
+ if (sessionID !== "session-1") {
|
|
|
+ return ok([])
|
|
|
+ }
|
|
|
+
|
|
|
+ return ok([
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ completedTool({
|
|
|
+ sessionID: "session-1",
|
|
|
+ messageID: "msg-1",
|
|
|
+ id: "task-1",
|
|
|
+ callID: "call-1",
|
|
|
+ tool: "task",
|
|
|
+ body: {
|
|
|
+ description: "Explore run folder",
|
|
|
+ subagent_type: "explore",
|
|
|
+ },
|
|
|
+ metadata: {
|
|
|
+ sessionId: "child-1",
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
+ },
|
|
|
+ children: async () => ok([child("child-1")]),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ const state = await waitFor(() => {
|
|
|
+ const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
|
|
+ return item?.type === "stream.subagent" ? item.state : undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(state.tabs).toEqual([])
|
|
|
+ expect(state.details).toEqual({})
|
|
|
+ } finally {
|
|
|
+ src.close()
|
|
|
+ await transport.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
test("bootstraps child tabs and resumed blocker input", async () => {
|
|
|
const src = eventFeed()
|
|
|
const ui = footer()
|
|
|
@@ -487,7 +854,7 @@ describe("run stream transport", () => {
|
|
|
expect.objectContaining({
|
|
|
sessionID: "child-1",
|
|
|
label: "Explore",
|
|
|
- description: "Explore run folder",
|
|
|
+ description: "Pending permission",
|
|
|
status: "running",
|
|
|
}),
|
|
|
])
|
|
|
@@ -565,16 +932,16 @@ describe("run stream transport", () => {
|
|
|
messages: async ({ sessionID }) => {
|
|
|
if (sessionID === "session-1") {
|
|
|
return ok([
|
|
|
- assistantMessage({
|
|
|
- sessionID: "session-1",
|
|
|
- id: "msg-1",
|
|
|
- parts: [
|
|
|
- completedTool({
|
|
|
- sessionID: "session-1",
|
|
|
- messageID: "msg-1",
|
|
|
- id: "task-1",
|
|
|
- callID: "call-1",
|
|
|
- tool: "task",
|
|
|
+ assistantMessage({
|
|
|
+ sessionID: "session-1",
|
|
|
+ id: "msg-1",
|
|
|
+ parts: [
|
|
|
+ runningTool({
|
|
|
+ sessionID: "session-1",
|
|
|
+ messageID: "msg-1",
|
|
|
+ id: "task-1",
|
|
|
+ callID: "call-1",
|
|
|
+ tool: "task",
|
|
|
body: {
|
|
|
description: "Explore run.ts",
|
|
|
subagent_type: "explore",
|
|
|
@@ -582,10 +949,10 @@ describe("run stream transport", () => {
|
|
|
metadata: {
|
|
|
sessionId: "child-1",
|
|
|
},
|
|
|
- }),
|
|
|
- ],
|
|
|
- }),
|
|
|
- ])
|
|
|
+ }),
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ])
|
|
|
}
|
|
|
|
|
|
return sessionID === "child-1"
|
|
|
@@ -711,6 +1078,109 @@ describe("run stream transport", () => {
|
|
|
}
|
|
|
})
|
|
|
|
|
|
+ test("replays child events buffered during bootstrap once the tab is known", async () => {
|
|
|
+ const global = globalFeed()
|
|
|
+ const ui = footer()
|
|
|
+ const gate = defer<void>()
|
|
|
+ let transport: Awaited<ReturnType<typeof createSessionTransport>> | undefined
|
|
|
+ const task = createSessionTransport({
|
|
|
+ sdk: sdk({
|
|
|
+ globalStream: global.stream,
|
|
|
+ messages: async ({ sessionID }) => {
|
|
|
+ if (sessionID !== "session-1") {
|
|
|
+ return ok([])
|
|
|
+ }
|
|
|
+
|
|
|
+ await gate.promise
|
|
|
+ return ok([])
|
|
|
+ },
|
|
|
+ children: async () => ok([]),
|
|
|
+ }),
|
|
|
+ sessionID: "session-1",
|
|
|
+ thinking: true,
|
|
|
+ limits: () => ({}),
|
|
|
+ footer: ui.api,
|
|
|
+ })
|
|
|
+
|
|
|
+ try {
|
|
|
+ await Promise.resolve()
|
|
|
+ global.push(globalEvent(retry("child-1", 1, "retry child")))
|
|
|
+ global.push(
|
|
|
+ globalEvent({
|
|
|
+ id: "evt-child-message",
|
|
|
+ type: "message.updated",
|
|
|
+ properties: {
|
|
|
+ sessionID: "child-1",
|
|
|
+ info: assistantMessage({
|
|
|
+ sessionID: "child-1",
|
|
|
+ id: "msg-child-1",
|
|
|
+ parts: [],
|
|
|
+ }).info,
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "", "child-1"))))
|
|
|
+ global.push(globalEvent(textDelta("msg-child-1", "txt-child-1", "Hello", "child-1")))
|
|
|
+ global.push(
|
|
|
+ globalEvent(
|
|
|
+ toolUpdated(
|
|
|
+ runningTool({
|
|
|
+ sessionID: "session-1",
|
|
|
+ messageID: "msg-1",
|
|
|
+ id: "task-1",
|
|
|
+ callID: "call-1",
|
|
|
+ tool: "task",
|
|
|
+ body: {
|
|
|
+ description: "Explore run.ts",
|
|
|
+ subagent_type: "explore",
|
|
|
+ },
|
|
|
+ metadata: {
|
|
|
+ sessionId: "child-1",
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ gate.resolve()
|
|
|
+ transport = await task
|
|
|
+
|
|
|
+ await waitFor(() => {
|
|
|
+ const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
|
|
+ return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1")
|
|
|
+ ? item
|
|
|
+ : undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ transport.selectSubagent("child-1")
|
|
|
+
|
|
|
+ const detail = await waitFor(() => {
|
|
|
+ const item = ui.events.findLast((event) => event.type === "stream.subagent")
|
|
|
+ const next = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined
|
|
|
+ return next?.commits.some((commit) => commit.kind === "error" && commit.text === "retry child") &&
|
|
|
+ next.commits.some((commit) => commit.kind === "assistant" && commit.text === "Hello")
|
|
|
+ ? next
|
|
|
+ : undefined
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(detail).toEqual({
|
|
|
+ sessionID: "child-1",
|
|
|
+ commits: expect.arrayContaining([
|
|
|
+ expect.objectContaining({
|
|
|
+ kind: "error",
|
|
|
+ text: "retry child",
|
|
|
+ }),
|
|
|
+ expect.objectContaining({
|
|
|
+ kind: "assistant",
|
|
|
+ text: "Hello",
|
|
|
+ }),
|
|
|
+ ]),
|
|
|
+ })
|
|
|
+ } finally {
|
|
|
+ global.close()
|
|
|
+ await transport?.close()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
test("streams selected subagent output from global events while it is running", async () => {
|
|
|
const global = globalFeed()
|
|
|
const ui = footer()
|