Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 74e7137c10 fix(tui): keep closed tabs closed 2026-08-02 15:38:19 -04:00
Kit Langton 3f30203b72 test(core): stabilize shell integration timing (#40084) 2026-08-02 14:45:36 -04:00
5 changed files with 70 additions and 73 deletions
+9 -22
View File
@@ -191,31 +191,22 @@ export const layer = Layer.effect(
const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history
const toolDefinitions = tools.definitions
const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool]))
// Object identity preserves registry provenance when a hook moves a definition to a new key.
const toolNamesByDefinition = new Map<object, string>()
// Hooks may reshape available definitions but cannot advertise tools omitted by permissions or the Step limit.
const hookTools = Object.fromEntries(
toolDefinitions.map((tool) => {
const definition = { description: tool.description, input: { ...tool.inputSchema } }
toolNamesByDefinition.set(definition, tool.name)
return [tool.name, definition]
}),
)
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system,
messages,
tools: hookTools,
tools: Object.fromEntries(
toolDefinitions.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
})
const executableTools = new Map<string, string>()
const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => {
const registeredName = toolNamesByDefinition.get(tool)
const registered = registeredName ? toolsByName.get(registeredName) : undefined
if (!registered || !registeredName) return []
executableTools.set(name, registeredName)
return [{ ...registered, name, description: tool.description, inputSchema: tool.input }]
const registered = toolsByName.get(name)
return registered
? [{ ...registered, description: tool.description, inputSchema: tool.input }]
: []
})
const request = LLM.request({
model,
@@ -268,14 +259,10 @@ export const layer = Layer.effect(
const executeTool: Prepared["executeTool"] = (executeInput) => {
if (stepLimitReached)
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
const registeredName = executableTools.get(executeInput.call.name)
if (!registeredName && toolsByName.has(executeInput.call.name))
if (toolsByName.has(executeInput.call.name) && !Object.hasOwn(contextEvent.tools, executeInput.call.name))
return new Tool.Error({ message: `Tool is not available for this request: ${executeInput.call.name}` })
return tools
.execute({
...executeInput,
call: { ...executeInput.call, name: registeredName ?? executeInput.call.name },
})
.execute(executeInput)
.pipe(Effect.catchCauseFilter(declineDefect, (decline) => Effect.fail(decline)))
}
return {
-36
View File
@@ -887,42 +887,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("advertises and executes a tool renamed by a session context hook", () =>
Effect.gen(function* () {
const session = yield* setup
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
const tool = event.tools.echo
if (!tool) return
event.tools.renamed_echo = tool
delete event.tools.echo
}),
)
yield* admit(session, "Use the renamed tool")
yield* TestLLM.push(TestLLM.tool("call-renamed", "renamed_echo", { text: "renamed" }), [])
yield* session.resume(sessionID)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("renamed_echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(executions).toEqual(["renamed"])
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use the renamed tool" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-renamed",
state: { status: "completed", content: [{ type: "text", text: "renamed" }] },
},
],
},
])
}),
)
it.effect("advertises and executes a location registered tool", () =>
Effect.gen(function* () {
const session = yield* setup
+4 -1
View File
@@ -301,6 +301,7 @@ describe("ShellTool", () => {
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("rejects a workdir that stops being a directory during approval", () =>
@@ -472,6 +473,7 @@ describe("ShellTool", () => {
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live(
@@ -538,7 +540,7 @@ describe("ShellTool", () => {
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 500 : 50 })),
executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
@@ -557,6 +559,7 @@ describe("ShellTool", () => {
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
it.live("returns the shell id for a background command", () =>
+32 -14
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -126,22 +126,40 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
}
createEffect(
on(
[
() => (enabled() && route.data.type === "session" ? route.data.sessionID : undefined),
() => config.tabs?.scope,
() => paths.cwd,
],
([routed]) => {
if (!routed || routed === "dummy") return
const sessionID = root(routed)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
delete draft.unread[sessionID]
})
},
),
)
createEffect(() => {
if (!enabled()) return
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
if (!enabled() || route.data.type !== "session" || route.data.sessionID === "dummy") return
const sessionID = root(route.data.sessionID)
history = recordSessionTabHistory(history, sessionID)
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const tabs = openSessionTab(state().tabs, {
sessionID,
title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
if (tabs === state().tabs && !state().unread[sessionID]) return
const tab = state().tabs.find((tab) => tab.sessionID === sessionID)
if (!tab) return
const nextTitle = title(sessionID, tab.title)
if ((!nextTitle || nextTitle === tab.title) && !state().unread[sessionID]) return
update((draft) => {
draft.tabs = openSessionTab(draft.tabs, {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
const tab = draft.tabs.find((tab) => tab.sessionID === sessionID)
if (!tab) return
draft.tabs = openSessionTab(draft.tabs, { sessionID, title: title(sessionID, tab.title) })
delete draft.unread[sessionID]
})
})
@@ -170,6 +170,31 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
}
})
test("closing a tab is not undone by another TUI viewing the same session", async () => {
const state = stateDir("opencode-session-tabs-shared-close-")
const first = await renderSessionTabs("shared", { state })
const second = await renderSessionTabs("shared", { state })
try {
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
first.tabs.close()
await wait(() => first.route.data.type === "home")
await wait(() => !second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
await Bun.sleep(50)
expect(first.tabs.tabs().some((tab) => tab.sessionID === "shared")).toBe(false)
second.route.navigate({ type: "home" })
await wait(() => second.route.data.type === "home")
second.route.navigate({ type: "session", sessionID: "shared" })
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
} finally {
first.destroy()
second.destroy()
}
})
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({