فهرست منبع

feat(tui): hydrate pending session work

Dax Raad 1 ماه پیش
والد
کامیت
e689829315
3فایلهای تغییر یافته به همراه175 افزوده شده و 4 حذف شده
  1. 70 4
      packages/tui/src/context/data.tsx
  2. 104 0
      packages/tui/test/cli/tui/data.test.tsx
  3. 1 0
      packages/tui/test/fixture/tui-sdk.ts

+ 70 - 4
packages/tui/src/context/data.tsx

@@ -22,6 +22,7 @@ import type {
   SessionMessageAssistantText,
   SessionMessageAssistantTool,
   SessionInfo,
+  SessionPendingInfo,
   Shell,
   SkillInfo,
 } from "@opencode-ai/sdk/v2"
@@ -145,6 +146,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
         const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
         return item?.type === "compaction" ? item : undefined
       },
+      fromPending(item: SessionPendingInfo): SessionMessageInfo {
+        if (item.type === "user")
+          return {
+            id: item.id,
+            type: "user",
+            ...item.data,
+            time: { created: item.timeCreated },
+          }
+        if (item.type === "synthetic")
+          return {
+            id: item.id,
+            type: "synthetic",
+            ...item.data,
+            time: { created: item.timeCreated },
+          }
+        return {
+          id: item.id,
+          type: "compaction",
+          status: "running",
+          reason: "manual",
+          summary: "",
+          recent: "",
+          time: { created: item.timeCreated },
+        }
+      },
       latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
         return assistant?.content.findLast(
           (item): item is SessionMessageAssistantTool =>
@@ -612,11 +638,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
           setSessionStatus(event.data.sessionID, "running")
           break
         case "session.compaction.admitted":
+          message.update(event.data.sessionID, (draft, index) => {
+            message.append(draft, index, {
+              id: event.data.inputID,
+              type: "compaction",
+              status: "running",
+              reason: "manual",
+              summary: "",
+              recent: "",
+              time: { created: event.created },
+            })
+          })
           break
         case "session.compaction.started":
           message.update(event.data.sessionID, (draft, index) => {
+            const id = event.data.inputID ?? messageIDFromEvent(event.id)
+            const position = index.get(id)
+            const existing = position === undefined ? undefined : draft[position]
+            if (position !== undefined && existing?.type === "compaction") {
+              draft[position] = {
+                id,
+                type: "compaction",
+                status: "running",
+                reason: event.data.reason,
+                summary: existing.status === "running" ? existing.summary : "",
+                recent: event.data.recent,
+                metadata: existing.metadata,
+                time: existing.time,
+              }
+              return
+            }
             message.append(draft, index, {
-              id: event.data.inputID ?? messageIDFromEvent(event.id),
+              id,
               type: "compaction",
               status: "running",
               reason: event.data.reason,
@@ -836,9 +889,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             return position === undefined ? undefined : messages?.[position]
           },
           async refresh(sessionID: string) {
-            const messages = (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
-            messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
-            setStore("session", "message", sessionID, reconcile(messages))
+            const [projected, pending] = await Promise.all([
+              sdk.api.message.list({ sessionID, limit: 200, order: "desc" }),
+              sdk.api.session.pending.list({ sessionID }),
+            ])
+            const messages = projected.data.toReversed()
+            const projectedIDs = new Set(messages.map((message) => message.id))
+            const pendingMessages = pending.filter((item) => !projectedIDs.has(item.id)).map(message.fromPending)
+            const next = [...messages, ...pendingMessages]
+            messageIndex.set(sessionID, new Map(next.map((message, index) => [message.id, index])))
+            setStore(
+              "session",
+              "input",
+              sessionID,
+              pendingMessages.flatMap((item) => (item.type === "user" || item.type === "synthetic" ? [item.id] : [])),
+            )
+            setStore("session", "message", sessionID, reconcile(next))
           },
         },
         permission: {

+ 104 - 0
packages/tui/test/cli/tui/data.test.tsx

@@ -2091,6 +2091,110 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
   }
 })
 
+test("refreshes pending session work into message state", async () => {
+  const events = createEventStream()
+  const sessionID = "session-pending"
+  const calls = createFetch((url) => {
+    if (url.pathname === `/api/session/${sessionID}/message`)
+      return json({
+        data: [{ id: "msg_projected", type: "user", text: "already visible", time: { created: 1 } }],
+        cursor: {},
+      })
+    if (url.pathname === `/api/session/${sessionID}/pending`)
+      return json({
+        data: [
+          {
+            admittedSeq: 1,
+            id: "msg_projected",
+            sessionID,
+            timeCreated: 1,
+            type: "user",
+            data: { text: "already visible" },
+            delivery: "steer",
+          },
+          {
+            admittedSeq: 2,
+            id: "msg_pending_user",
+            sessionID,
+            timeCreated: 2,
+            type: "user",
+            data: { text: "queued user", metadata: { source: "test" } },
+            delivery: "queue",
+          },
+          {
+            admittedSeq: 3,
+            id: "msg_pending_synthetic",
+            sessionID,
+            timeCreated: 3,
+            type: "synthetic",
+            data: { text: "internal", description: "Queued synthetic" },
+            delivery: "steer",
+          },
+          {
+            admittedSeq: 4,
+            id: "msg_pending_compaction",
+            sessionID,
+            timeCreated: 4,
+            type: "compaction",
+          },
+        ],
+      })
+  }, 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(sessionID)
+    expect(data.session.message.ids(sessionID)).toEqual([
+      "msg_projected",
+      "msg_pending_user",
+      "msg_pending_synthetic",
+      "msg_pending_compaction",
+    ])
+    expect(data.session.input.list(sessionID)).toEqual(["msg_pending_user", "msg_pending_synthetic"])
+    expect(data.session.message.get(sessionID, "msg_pending_user")).toMatchObject({
+      id: "msg_pending_user",
+      type: "user",
+      text: "queued user",
+      metadata: { source: "test" },
+      time: { created: 2 },
+    })
+    expect(data.session.message.get(sessionID, "msg_pending_synthetic")).toMatchObject({
+      id: "msg_pending_synthetic",
+      type: "synthetic",
+      text: "internal",
+      description: "Queued synthetic",
+      time: { created: 3 },
+    })
+    expect(data.session.message.get(sessionID, "msg_pending_compaction")).toMatchObject({
+      id: "msg_pending_compaction",
+      type: "compaction",
+      status: "running",
+      summary: "",
+      recent: "",
+      time: { created: 4 },
+    })
+  } finally {
+    app.renderer.destroy()
+  }
+})
+
 test("projects live instruction updates with their message ID", async () => {
   const events = createEventStream()
   const calls = createFetch(undefined, events)

+ 1 - 0
packages/tui/test/fixture/tui-sdk.ts

@@ -102,6 +102,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
       return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
     if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
     if (url.pathname === "/api/session/active") return json({ data: {} })
+    if (/^\/api\/session\/[^/]+\/pending$/.test(url.pathname)) return json({ data: [] })
     if (url.pathname === "/api/permission/request")
       return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
     if (url.pathname === "/api/form/request")