Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export type CreateDataInput = {
}
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
readonly onError?: (error: unknown) => void
/** Predicate returning whether a session is followed and eligible to project live events. */
readonly isFollowed?: (sessionID: string) => boolean
}

const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
Expand Down Expand Up @@ -284,7 +286,20 @@ export function createData(config: CreateDataInput) {
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
const pendingUpdates = new Map<string, Map<string, SessionInboxInfo | SessionInbox.Delivery | undefined>>()

function isFollowed(sessionID: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decides whether to follow a session from config.isFollowed, from resolveRoot and from loaded transcripts. A new subagent child has no info loaded yet, so resolveRoot(child) === child, and its session.created is now skipped (line 688), so its info is never loaded. Every later event for the child is dropped, including permission.asked. I reproduced this with a test against createData: on this branch the child's permission and running status are lost, while on the base they are stored. In the TUI, a subagent's permission prompt wouldn't appear while the parent session is on screen.

if (config.isFollowed) {
if (config.isFollowed(sessionID)) return true
const root = resolveRoot(sessionID)
if (root !== sessionID && config.isFollowed(root)) return true
if (store.session.message[sessionID] !== undefined) return true
if (root !== sessionID && store.session.message[root] !== undefined) return true
return false
}
return true
}

function setSessionActive(sessionID: string, status: DataSessionStatus) {
if (!store.session.info[sessionID] && !isFollowed(sessionID)) return
activeUpdates?.set(sessionID, status)
setStore("session", "active", sessionID, status)
}
Expand Down Expand Up @@ -448,10 +463,12 @@ export function createData(config: CreateDataInput) {

const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
if (!isFollowed(sessionID) && store.session.message[sessionID] === undefined) return
setStore(
"session",
"message",
produce((draft) => {
if (!isFollowed(sessionID) && draft[sessionID] === undefined) return
fn((draft[sessionID] ??= []), index(sessionID))
}),
)
Expand Down Expand Up @@ -668,7 +685,7 @@ export function createData(config: CreateDataInput) {
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
case "session.created":
case "session.created": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping the sync for sessions this client didn't create also stops new sessions from other clients appearing in the session list, which reads data.session from the store. And it is what cuts subagent children off from their followed parent.

sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
Expand All @@ -679,6 +696,7 @@ export function createData(config: CreateDataInput) {
sync.complete(`session.pending:${event.data.sessionID}`)
sync.complete(`session.message:${event.data.sessionID}`)
return
}
case "session.deleted":
removeSession(event.data.sessionID)
return
Expand Down Expand Up @@ -814,6 +832,7 @@ export function createData(config: CreateDataInput) {
return
}
case "session.inbox.enqueued": {
if (!isFollowed(event.data.sessionID)) return
outbox.delete(event.data.inboxID)
const item = {
id: event.data.inboxID,
Expand All @@ -832,6 +851,7 @@ export function createData(config: CreateDataInput) {
return
}
case "session.instructions.updated":
if (!isFollowed(event.data.sessionID)) return
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
// and produce no transcript message.
const updateText = event.data.text
Expand All @@ -846,6 +866,7 @@ export function createData(config: CreateDataInput) {
})
return
case "session.synthetic":
if (!isFollowed(event.data.sessionID)) return
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "synthetic",
Expand All @@ -856,6 +877,7 @@ export function createData(config: CreateDataInput) {
})
return
case "session.shell.started":
if (!isFollowed(event.data.sessionID)) return
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "shell",
Expand All @@ -869,6 +891,7 @@ export function createData(config: CreateDataInput) {
})
return
case "session.shell.ended":
if (!isFollowed(event.data.sessionID)) return
message.update(event.data.sessionID, (draft) => {
const match = message.shell(draft, event.data.shell.id)
if (!match) return
Expand All @@ -879,6 +902,7 @@ export function createData(config: CreateDataInput) {
})
return
case "session.step.started":
if (!isFollowed(event.data.sessionID)) return
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.assistantMessageID)
const existing = position === undefined ? undefined : draft[position]
Expand Down Expand Up @@ -1053,6 +1077,7 @@ export function createData(config: CreateDataInput) {
setSessionActive(event.data.sessionID, "running")
return
case "session.compaction.started":
if (!isFollowed(event.data.sessionID)) return
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.insert(event.data.sessionID, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
Expand All @@ -1069,6 +1094,7 @@ export function createData(config: CreateDataInput) {
case "session.execution.failed":
case "session.execution.interrupted":
setSessionActive(event.data.sessionID, "idle")
if (!isFollowed(event.data.sessionID) && !store.session.message[event.data.sessionID]) return
message.update(event.data.sessionID, (draft) => {
const currentAssistant = message.activeAssistant(draft)
if (currentAssistant) currentAssistant.retry = undefined
Expand Down Expand Up @@ -1136,12 +1162,14 @@ export function createData(config: CreateDataInput) {
})
return
case "session.compaction.delta":
if (!isFollowed(event.data.sessionID)) return
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
if (current?.status === "running") current.summary += event.data.text
})
return
case "session.compaction.ended":
if (!isFollowed(event.data.sessionID)) return
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
Expand Down Expand Up @@ -1176,6 +1204,7 @@ export function createData(config: CreateDataInput) {
})
return
case "session.compaction.failed":
if (!isFollowed(event.data.sessionID)) return
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
Expand Down Expand Up @@ -1204,6 +1233,7 @@ export function createData(config: CreateDataInput) {
return
case "permission.asked":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session-tabs.tsx (attention), dialog-open.tsx (awaiting input, #53435) and the session list (running indicator) read permissions, forms and status for sessions other than the one on screen. With the TUI predicate, these events are dropped for any session not opened in this process. The matching permission.replied/form.replied guards also mean a prompt resolved elsewhere stays in the store if it was loaded earlier through a permission.sync.

if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
if (!store.session.info[event.data.sessionID] && !isFollowed(event.data.sessionID)) return
setStore("session", "permission", event.data.sessionID, [
...(store.session.permission[event.data.sessionID] ?? []),
event.data,
Expand Down Expand Up @@ -1249,6 +1279,8 @@ export function createData(config: CreateDataInput) {

if (!event.location) return
const location = event.location
const locKey = locationKey(location)
if (!store.location[locKey] && locKey !== locationKey(defaultLocation())) return
switch (event.type) {
case "provider.updated":
result.location.provider.invalidate(location)
Expand Down Expand Up @@ -1281,6 +1313,7 @@ export function createData(config: CreateDataInput) {
}))
break
case "form.created":
if (event.data.form.sessionID !== "global" && !store.session.info[event.data.form.sessionID] && !isFollowed(event.data.form.sessionID)) break
if (store.session.form[event.data.form.sessionID]?.some((form) => form.id === event.data.form.id)) break
setStore("session", "form", event.data.form.sessionID, [
...(store.session.form[event.data.form.sessionID] ?? []),
Expand Down
90 changes: 90 additions & 0 deletions packages/client/test/solid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,3 +1245,93 @@ async function wait(check: () => boolean) {
await Bun.sleep(10)
}
}

test("ignores foreign session transcript events until followed while preserving subagent family events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const path = new URL(request.url).pathname
if (path === "/api/session/ses_child") {
return Response.json({ data: { ...session(0), id: "ses_child", parentID: "ses_active" } })
}
if (path === "/api/session/ses_foreign") {
return Response.json({ data: { ...session(0), id: "ses_foreign" } })
}
return Response.json([])
},
})

let activeSessionID = "ses_active"
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
isFollowed: (sessionID) => sessionID === activeSessionID,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
dispose,
}))

try {
// 1. Foreign session step/message event should NOT allocate message list or index
setup.emit({
type: "session.step.started",
id: "evt_foreign_step",
created: 1,
data: {
sessionID: "ses_foreign",
assistantMessageID: "msg_foreign_1",
started: 1,
agent: "build",
model: { providerID: "provider", id: "model" },
},
} as any)
expect(setup.data.session.message.list("ses_foreign")).toHaveLength(0)

// 2. Subagent child created under followed parent
setup.emit({
type: "session.created",
id: "evt_child_created",
created: 2,
data: { sessionID: "ses_child" },
} as any)
await wait(() => setup.data.session.get("ses_child")?.parentID === "ses_active")
expect(setup.data.session.root("ses_child")).toBe("ses_active")

// 3. Subagent child permission is preserved and visible
setup.emit({
type: "permission.asked",
id: "evt_child_perm",
created: 3,
data: {
id: "perm_1",
sessionID: "ses_child",
permission: "terminal.execute",
patterns: ["ls"],
metadata: {},
always: [],
},
} as any)
expect(setup.data.session.permission.list("ses_child")).toHaveLength(1)

// 4. Followed active session events project normally
setup.emit({
type: "session.execution.started",
id: "evt_active_started",
created: 4,
data: { sessionID: "ses_active" },
} as any)
expect(setup.data.session.status("ses_active")).toBe("running")
} finally {
setup.dispose()
}
})
8 changes: 8 additions & 0 deletions packages/tui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Plugin } from "@opencode/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useRoute } from "./route"

export { locationKey } from "@opencode/client/solid"
export type { FormWithLocation } from "@opencode/client/solid"
Expand All @@ -12,11 +13,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: { directory: string }) => {
const client = useClient()
let route: ReturnType<typeof useRoute> | undefined
try {
route = useRoute()
} catch {
// Allow mounting DataProvider outside RouteProvider in isolated unit tests
}
const data = createData({
api: () => client.api,
event: client.event,
connection: client.connection,
directory: props.directory,
isFollowed: (sessionID) => (route ? route.data.type === "session" && route.data.sessionID === sessionID : true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats only the session currently on screen as followed. Its subagents, background tabs and sessions listed in the open menu or session list are not covered. The predicate needs to include at least the route session's family and the open tabs, or the filtering in createData needs to be limited to allocation for sessions it doesn't know yet.

})
data satisfies Plugin.Context["data"]
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
Expand Down
Loading