| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593 |
- import { describe, expect, test } from "bun:test"
- import type { Event } from "@kirincode-ai/sdk/v2"
- import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
- import type { StreamCommit } from "@/cli/cmd/run/types"
- function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
- return reduceSessionData({
- data,
- event: event as Event,
- sessionID: "session-1",
- thinking,
- limits: {},
- })
- }
- function assistant(id: string, extra: Record<string, unknown> = {}) {
- return {
- type: "message.updated",
- properties: {
- sessionID: "session-1",
- info: {
- id,
- role: "assistant",
- providerID: "openai",
- modelID: "gpt-5",
- tokens: {
- input: 1,
- output: 1,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- ...extra,
- },
- },
- }
- }
- function user(id: string) {
- return {
- type: "message.updated",
- properties: {
- sessionID: "session-1",
- info: {
- id,
- role: "user",
- },
- },
- }
- }
- function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
- return {
- type: "message.part.updated",
- properties: {
- part: {
- id: input.id,
- messageID: input.messageID,
- sessionID: "session-1",
- type: "text",
- text: input.text,
- ...(input.time ? { time: input.time } : {}),
- },
- },
- }
- }
- function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
- return {
- type: "message.part.updated",
- properties: {
- part: {
- id: input.id,
- messageID: input.messageID,
- sessionID: "session-1",
- type: "reasoning",
- text: input.text,
- ...(input.time ? { time: input.time } : {}),
- },
- },
- }
- }
- function delta(messageID: string, partID: string, value: string) {
- return {
- type: "message.part.delta",
- properties: {
- sessionID: "session-1",
- messageID,
- partID,
- field: "text",
- delta: value,
- },
- }
- }
- function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
- return {
- type: "message.part.updated",
- properties: {
- part: {
- id: input.id,
- messageID: input.messageID,
- sessionID: "session-1",
- type: "tool",
- tool: input.tool,
- ...(input.callID ? { callID: input.callID } : {}),
- state: input.state,
- },
- },
- }
- }
- describe("run session data", () => {
- test("buffers delayed assistant text until the role is known", () => {
- let data = createSessionData()
- data = reduce(data, delta("msg-1", "txt-1", "hello")).data
- data = reduce(data, assistant("msg-1")).data
- const out = reduce(
- data,
- text({
- id: "txt-1",
- messageID: "msg-1",
- text: "",
- time: { end: 1 },
- }),
- )
- expect(out.commits).toEqual([
- expect.objectContaining({
- kind: "assistant",
- text: "hello",
- partID: "txt-1",
- }),
- ])
- })
- test("keeps leading whitespace buffered until real assistant content arrives", () => {
- let data = createSessionData()
- data = reduce(data, assistant("msg-1")).data
- data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
- let out = reduce(data, delta("msg-1", "txt-1", " "))
- expect(out.commits).toEqual([])
- out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
- expect(out.commits).toEqual([
- expect.objectContaining({
- kind: "assistant",
- text: " Found",
- }),
- ])
- })
- test("drops delayed text once the message resolves to a user role", () => {
- let data = createSessionData()
- data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
- const out = reduce(data, user("msg-user-1"))
- expect(out.commits).toEqual([])
- expect(out.data.ids.has("txt-user-1")).toBe(true)
- })
- test("suppresses reasoning commits when thinking is disabled", () => {
- const out = reduce(
- createSessionData(),
- reasoning({
- id: "reason-1",
- messageID: "msg-1",
- text: "hidden",
- time: { end: 1 },
- }),
- false,
- )
- expect(out.commits).toEqual([])
- expect(out.data.ids.has("reason-1")).toBe(true)
- })
- test("keeps permission precedence over queued questions", () => {
- let data = createSessionData()
- data = reduce(data, {
- type: "permission.asked",
- properties: {
- id: "perm-1",
- sessionID: "session-1",
- permission: "read",
- patterns: ["/tmp/file.txt"],
- metadata: {},
- always: [],
- },
- }).data
- const ask = reduce(data, {
- type: "question.asked",
- properties: {
- id: "question-1",
- sessionID: "session-1",
- questions: [
- {
- question: "Mode?",
- header: "Mode",
- options: [{ label: "chunked", description: "Incremental output" }],
- multiple: false,
- },
- ],
- },
- })
- expect(ask.footer).toEqual({
- patch: { status: "awaiting permission" },
- view: {
- type: "permission",
- request: expect.objectContaining({ id: "perm-1" }),
- },
- })
- expect(
- reduce(ask.data, {
- type: "permission.replied",
- properties: {
- sessionID: "session-1",
- requestID: "perm-1",
- reply: "reject",
- },
- }).footer,
- ).toEqual({
- patch: { status: "awaiting answer" },
- view: {
- type: "question",
- request: expect.objectContaining({ id: "question-1" }),
- },
- })
- })
- test("refreshes the active permission view when tool input arrives later", () => {
- const data = reduce(createSessionData(), {
- type: "permission.asked",
- properties: {
- id: "perm-1",
- sessionID: "session-1",
- permission: "bash",
- patterns: ["src/**/*.ts"],
- metadata: {},
- always: [],
- tool: {
- messageID: "msg-1",
- callID: "call-1",
- },
- },
- }).data
- const out = reduce(
- data,
- tool({
- id: "tool-1",
- messageID: "msg-1",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "running",
- input: {
- command: "git status --short",
- },
- },
- }),
- )
- expect(out.footer).toEqual({
- view: {
- type: "permission",
- request: expect.objectContaining({
- id: "perm-1",
- metadata: expect.objectContaining({
- input: {
- command: "git status --short",
- },
- }),
- }),
- },
- })
- })
- test("strips bash echo only from the first assistant flush", () => {
- let data = createSessionData()
- data = reduce(data, assistant("msg-1")).data
- data = reduce(
- data,
- tool({
- id: "tool-1",
- messageID: "msg-1",
- tool: "bash",
- state: {
- status: "completed",
- input: {
- command: "printf hi",
- },
- output: "echoed\n",
- time: { start: 1, end: 2 },
- },
- }),
- ).data
- const first = reduce(
- data,
- text({
- id: "txt-1",
- messageID: "msg-1",
- text: "echoed\nanswer",
- }),
- )
- expect(first.commits).toEqual([
- expect.objectContaining({
- kind: "assistant",
- text: "answer",
- }),
- ])
- expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
- expect.objectContaining({
- kind: "assistant",
- text: "\nechoed\nagain",
- }),
- ])
- })
- test("renders direct shell mode from first-class shell events", () => {
- let data = createSessionData()
- const started = reduce(data, {
- type: "session.next.shell.started",
- properties: {
- sessionID: "session-1",
- timestamp: 1,
- callID: "call-1",
- command: "pwd",
- },
- })
- expect(started.commits).toEqual([
- expect.objectContaining({
- kind: "tool",
- phase: "start",
- partID: "shell:call-1",
- tool: "bash",
- shell: {
- callID: "call-1",
- command: "pwd",
- },
- }),
- ])
- data = started.data
- const ended = reduce(data, {
- type: "session.next.shell.ended",
- properties: {
- sessionID: "session-1",
- timestamp: 2,
- callID: "call-1",
- output: "/tmp/demo\n",
- },
- })
- expect(ended.commits).toEqual([
- expect.objectContaining({
- kind: "tool",
- phase: "progress",
- partID: "shell:call-1",
- tool: "bash",
- text: "/tmp/demo\n",
- toolState: "completed",
- shell: {
- callID: "call-1",
- command: "pwd",
- },
- }),
- ])
- })
- test("suppresses legacy bash part updates once shell events claim the call", () => {
- let data = reduce(createSessionData(), {
- type: "session.next.shell.started",
- properties: {
- sessionID: "session-1",
- timestamp: 1,
- callID: "call-1",
- command: "pwd",
- },
- }).data
- expect(
- reduce(
- data,
- tool({
- id: "tool-1",
- messageID: "msg-1",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "running",
- input: {
- command: "pwd",
- },
- time: { start: 1 },
- },
- }),
- ).commits,
- ).toEqual([])
- data = reduce(data, {
- type: "session.next.shell.ended",
- properties: {
- sessionID: "session-1",
- timestamp: 2,
- callID: "call-1",
- output: "/tmp/demo\n",
- },
- }).data
- expect(
- reduce(
- data,
- tool({
- id: "tool-1",
- messageID: "msg-1",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: {
- command: "pwd",
- },
- output: "/tmp/demo\n",
- title: "",
- metadata: {
- output: "/tmp/demo\n",
- },
- time: { start: 1, end: 2 },
- },
- }),
- ).commits,
- ).toEqual([])
- })
- test("suppresses shell events when the legacy bash part claimed the call first", () => {
- let data = reduce(
- createSessionData(),
- tool({
- id: "tool-1",
- messageID: "msg-1",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "running",
- input: {
- command: "pwd",
- },
- time: { start: 1 },
- },
- }),
- ).data
- expect(
- reduce(data, {
- type: "session.next.shell.started",
- properties: {
- sessionID: "session-1",
- timestamp: 1,
- callID: "call-1",
- command: "pwd",
- },
- }).commits,
- ).toEqual([])
- data = reduce(
- data,
- tool({
- id: "tool-1",
- messageID: "msg-1",
- callID: "call-1",
- tool: "bash",
- state: {
- status: "completed",
- input: {
- command: "pwd",
- },
- output: "/tmp/demo\n",
- title: "",
- metadata: {
- output: "/tmp/demo\n",
- },
- time: { start: 1, end: 2 },
- },
- }),
- ).data
- expect(
- reduce(data, {
- type: "session.next.shell.ended",
- properties: {
- sessionID: "session-1",
- timestamp: 2,
- callID: "call-1",
- output: "/tmp/demo\n",
- },
- }).commits,
- ).toEqual([])
- })
- test("synthesizes a glob start before an error when the running update is missed", () => {
- expect(
- reduce(
- createSessionData(),
- tool({
- id: "tool-1",
- messageID: "msg-1",
- tool: "glob",
- state: {
- status: "error",
- input: {
- pattern: "**/*tool*",
- path: "/tmp/demo/run",
- },
- error: "No such file or directory: '/tmp/demo/run'",
- },
- }),
- ).commits,
- ).toEqual([
- expect.objectContaining({
- kind: "tool",
- tool: "glob",
- phase: "start",
- partID: "tool-1",
- text: "running glob",
- toolState: "running",
- }),
- expect.objectContaining({
- kind: "tool",
- tool: "glob",
- phase: "final",
- partID: "tool-1",
- text: "No such file or directory: '/tmp/demo/run'",
- toolState: "error",
- toolError: "No such file or directory: '/tmp/demo/run'",
- }),
- ])
- })
- test("flushInterrupted emits one interrupted final per live part", () => {
- const data = reduce(
- createSessionData(),
- text({
- id: "txt-1",
- messageID: "msg-1",
- text: "unfinished",
- }),
- ).data
- const first: StreamCommit[] = []
- flushInterrupted(data, first)
- expect(first).toEqual([
- expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
- expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
- ])
- const next: StreamCommit[] = []
- flushInterrupted(data, next)
- expect(next).toEqual([])
- })
- test("surfaces session errors as error commits", () => {
- const out = reduce(createSessionData(), {
- type: "session.error",
- properties: {
- sessionID: "session-1",
- error: {
- name: "UnknownError",
- data: {
- message: "permission denied",
- },
- },
- },
- })
- expect(out.commits).toEqual([
- expect.objectContaining({
- kind: "error",
- text: "permission denied",
- }),
- ])
- })
- })
|