feat(tui): refresh agents after update events

This commit is contained in:
Dax Raad 2026-06-29 18:52:00 -04:00
commit 02cb350880
17 changed files with 576 additions and 30 deletions

View file

@ -3,6 +3,7 @@ export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { EventV2 } from "./event"
import { State } from "./state"
export const ID = Agent.ID
@ -14,6 +15,8 @@ export const Color = Agent.Color
export const Info = Agent.Info
export type Info = Agent.Info
export const Event = Agent.Event
export interface Selection {
readonly id: ID
readonly info: Info | undefined
@ -45,6 +48,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }),
draft: (draft) => ({
@ -63,6 +67,7 @@ export const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
@ -108,4 +113,4 @@ export const layer = Layer.effect(
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })

View file

@ -60,6 +60,7 @@ export const Plugin = define({
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
yield* Effect.log({ msg: "applying permissions", id: current.id, permissions: global })
draft.update(current.id, (agent) => agent.permissions.push(...global))
}

View file

@ -76,7 +76,7 @@ export const Plugin = define({
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
@ -86,6 +86,6 @@ export const Plugin = define({
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
})
}),
})

View file

@ -114,11 +114,11 @@ const layer = Layer.effectDiscard(
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
// Embedder-contributed plugins are added last so they layer over config.

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -8,9 +9,32 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const it = testEffect(AgentV2.locationLayer)
const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const it = testEffect(
AgentV2.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
describe("AgentV2", () => {
it.effect("publishes an updated event after agent changes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(AgentV2.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), () => {}))
expect(yield* Fiber.join(updated)).toMatchObject([{ location: { directory: testLocation.directory } }])
}),
)
it.effect("starts without agents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service

View file

@ -5,6 +5,7 @@ import { Effect, Layer, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -12,7 +13,9 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
const it = testEffect(
Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer),
)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {

View file

@ -59,7 +59,7 @@ const permission = Layer.succeed(
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const agents = AgentV2.layer
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const model = OpenAIChat.route
.with({
endpoint: { baseURL: "https://api.openai.com/v1" },

View file

@ -129,7 +129,7 @@ const registry = ToolRegistry.layer.pipe(
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const agents = AgentV2.layer
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.register({

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Agent } from "@opencode-ai/schema"
import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest"
import { Todo } from "@/session/todo"
import { EventManifest } from "@/event-manifest"
@ -9,8 +10,9 @@ describe("public event manifest", () => {
expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
expect(EventManifest.Latest.size).toBe(88)
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(EventManifest.Latest.has("server.connected")).toBe(true)

View file

@ -1,12 +1,15 @@
export * as Agent from "./agent"
import { Schema } from "effect"
import { define, inventory } from "./event"
import { optional } from "./schema"
import { Model } from "./model"
import { Permission } from "./permission"
import { Provider } from "./provider"
import { PositiveInt, statics } from "./schema"
const Updated = define({ type: "agent.updated", schema: {} })
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
@ -33,6 +36,20 @@ export const Info = Schema.Struct({
.pipe(
statics((schema) => ({
empty: (id: ID) =>
schema.make({ id, request: { headers: {}, body: {} }, mode: "all", hidden: false, permissions: [] }),
schema.make({
id,
request: { headers: {}, body: {} },
mode: "all",
hidden: false,
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
],
}),
})),
)
export const Event = {
Updated,
Definitions: inventory(Updated),
}

View file

@ -1,5 +1,6 @@
export * as EventManifest from "./event-manifest"
import { Agent } from "./agent"
import { Catalog } from "./catalog"
import { Durable } from "./durable-event-manifest"
import { Event } from "./event"
@ -41,6 +42,7 @@ const foundationDefinitions = Event.inventory(
...ModelsDev.Event.Definitions,
...Integration.Event.Definitions,
...Catalog.Event.Definitions,
...Agent.Event.Definitions,
...coreDefinitions,
)

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src"
import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src"
import { EventManifest } from "../src/event-manifest"
import { IdeEvent } from "../src/ide-event"
import { SessionEvent } from "../src/session-event"
@ -9,8 +9,14 @@ import { WorkspaceEvent } from "../src/workspace-event"
describe("public event manifest", () => {
test("owns the complete public event surface", () => {
expect(EventManifest.ServerDefinitions.length).toBe(55)
expect(EventManifest.Definitions.length).toBe(85)
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
Agent.Event.Updated,
])
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
Agent.Event.Updated,
])
expect(SessionV1.Event.Definitions).toEqual([
SessionV1.Event.Created,
SessionV1.Event.Updated,
@ -23,8 +29,10 @@ describe("public event manifest", () => {
SessionV1.Event.Diff,
SessionV1.Event.Error,
])
expect(EventManifest.Latest.size).toBe(85)
expect(EventManifest.Durable.size).toBe(32)
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
})
test("uses canonical definitions for current public events", () => {
@ -34,7 +42,9 @@ describe("public event manifest", () => {
expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions)
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
@ -42,7 +52,8 @@ describe("public event manifest", () => {
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
expect(EventManifest.Definitions.slice(40, 43)).toEqual([
const sessionV1TailStart = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta)
expect(EventManifest.Definitions.slice(sessionV1TailStart, sessionV1TailStart + 3)).toEqual([
SessionV1.Event.PartDelta,
SessionV1.Event.Diff,
SessionV1.Event.Error,

View file

@ -279,6 +279,8 @@ import type {
V2FsListResponses,
V2FsReadErrors,
V2FsReadResponses,
V2GenerateTextErrors,
V2GenerateTextResponses,
V2HealthGetErrors,
V2HealthGetResponses,
V2IntegrationAttemptCancelErrors,
@ -311,6 +313,10 @@ import type {
V2ProjectCopyRefreshResponses,
V2ProjectCopyRemoveErrors,
V2ProjectCopyRemoveResponses,
V2ProjectCurrentErrors,
V2ProjectCurrentResponses,
V2ProjectDirectoriesErrors,
V2ProjectDirectoriesResponses,
V2ProviderGetErrors,
V2ProviderGetResponses,
V2ProviderListErrors,
@ -343,6 +349,8 @@ import type {
V2SessionCreateResponses,
V2SessionEventsErrors,
V2SessionEventsResponses,
V2SessionForkErrors,
V2SessionForkResponses,
V2SessionGetErrors,
V2SessionGetResponses,
V2SessionHistoryErrors,
@ -5548,6 +5556,41 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Fork session
*
* Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.
*/
public fork<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
messageID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "messageID" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionForkResponses, V2SessionForkErrors, ThrowOnError>({
url: "/api/session/{sessionID}/fork",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Switch session agent
*
@ -5944,6 +5987,48 @@ export class Model extends HeyApiClient {
}
}
export class Generate extends HeyApiClient {
/**
* Generate text
*
* Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified.
*/
public text<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
prompt?: string
model?: ModelRef
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "location" },
{ in: "body", key: "prompt" },
{ in: "body", key: "model" },
],
},
],
)
return (options?.client ?? this.client).post<V2GenerateTextResponses, V2GenerateTextErrors, ThrowOnError>({
url: "/api/generate",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Provider2 extends HeyApiClient {
/**
* List providers
@ -6363,6 +6448,67 @@ export class Credential extends HeyApiClient {
}
}
export class Project2 extends HeyApiClient {
/**
* Get current project
*
* Resolve the project for the requested location.
*/
public current<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<V2ProjectCurrentResponses, V2ProjectCurrentErrors, ThrowOnError>({
url: "/api/project/current",
...options,
...params,
})
}
/**
* List project directories
*
* List known local absolute directories for a project.
*/
public directories<ThrowOnError extends boolean = false>(
parameters: {
projectID: string
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "projectID" },
{ in: "query", key: "location" },
],
},
],
)
return (options?.client ?? this.client).get<
V2ProjectDirectoriesResponses,
V2ProjectDirectoriesErrors,
ThrowOnError
>({
url: "/api/project/{projectID}/directories",
...options,
...params,
})
}
}
export class Request extends HeyApiClient {
/**
* List pending permission requests
@ -7233,6 +7379,11 @@ export class V2 extends HeyApiClient {
return (this._model ??= new Model({ client: this.client }))
}
private _generate?: Generate
get generate(): Generate {
return (this._generate ??= new Generate({ client: this.client }))
}
private _provider?: Provider2
get provider(): Provider2 {
return (this._provider ??= new Provider2({ client: this.client }))
@ -7248,6 +7399,11 @@ export class V2 extends HeyApiClient {
return (this._credential ??= new Credential({ client: this.client }))
}
private _project?: Project2
get project(): Project2 {
return (this._project ??= new Project2({ client: this.client }))
}
private _permission?: Permission3
get permission(): Permission3 {
return (this._permission ??= new Permission3({ client: this.client }))

View file

@ -9,6 +9,7 @@ export type Event =
| EventIntegrationUpdated
| EventIntegrationConnectionUpdated
| EventCatalogUpdated
| EventAgentUpdated
| EventSessionCreated
| EventSessionUpdated
| EventSessionDeleted
@ -20,6 +21,7 @@ export type Event =
| EventSessionNextModelSwitched
| EventSessionNextMoved
| EventSessionNextRenamed
| EventSessionNextForked
| EventSessionNextPrompted
| EventSessionNextPromptAdmitted
| EventSessionNextContextUpdated
@ -646,7 +648,6 @@ export type Prompt = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
}
export type Pty = {
@ -783,6 +784,13 @@ export type GlobalEvent = {
[key: string]: unknown
}
}
| {
id: string
type: "agent.updated"
properties: {
[key: string]: unknown
}
}
| {
id: string
type: "session.created"
@ -880,6 +888,16 @@ export type GlobalEvent = {
title: string
}
}
| {
id: string
type: "session.next.forked"
properties: {
timestamp: number
sessionID: string
parentID: string
messageID?: string
}
}
| {
id: string
type: "session.next.prompted"
@ -1667,6 +1685,7 @@ export type GlobalEvent = {
| SyncEventSessionNextModelSwitched
| SyncEventSessionNextMoved
| SyncEventSessionNextRenamed
| SyncEventSessionNextForked
| SyncEventSessionNextPrompted
| SyncEventSessionNextPromptAdmitted
| SyncEventSessionNextContextUpdated
@ -2756,11 +2775,17 @@ export type SessionNotFoundError = {
message: string
}
export type MessageNotFoundError = {
_tag: "MessageNotFoundError"
sessionID: string
messageID: string
message: string
}
export type PromptInput = {
text: string
files?: Array<PromptInputFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
}
export type ConflictError = {
@ -2781,18 +2806,12 @@ export type UnknownError1 = {
ref?: string
}
export type MessageNotFoundError = {
_tag: "MessageNotFoundError"
sessionID: string
messageID: string
message: string
}
export type SessionDurableEvent =
| SessionNextAgentSwitched
| SessionNextModelSwitched
| SessionNextMoved
| SessionNextRenamed
| SessionNextForked
| SessionNextPrompted
| SessionNextPromptAdmitted
| SessionNextContextUpdated
@ -2834,6 +2853,12 @@ export type SessionMessagesResponse = {
}
}
export type GenerateTextResponse = {
data: {
text: string
}
}
export type ProviderNotFoundError = {
_tag: "ProviderNotFoundError"
providerID: string
@ -2928,6 +2953,7 @@ export type V2Event =
| IntegrationUpdated
| IntegrationConnectionUpdated
| CatalogUpdated
| AgentUpdated
| SessionCreated
| SessionUpdated
| SessionDeleted
@ -2939,6 +2965,7 @@ export type V2Event =
| SessionNextModelSwitched
| SessionNextMoved
| SessionNextRenamed
| SessionNextForked
| SessionNextPrompted
| SessionNextPromptAdmitted
| SessionNextContextUpdated
@ -3464,6 +3491,23 @@ export type SyncEventSessionNextRenamed = {
}
}
export type SyncEventSessionNextForked = {
type: "sync"
id: string
syncEvent: {
type: "session.next.forked.1"
id: string
seq: number
aggregateID: string
data: {
timestamp: number
sessionID: string
parentID: string
messageID?: string
}
}
}
export type SyncEventSessionNextPrompted = {
type: "sync"
id: string
@ -3959,10 +4003,12 @@ export type ConfigV2ExperimentalPolicy = {
resource: string
}
export type ProjectDirectories = Array<{
export type ProjectDirectory = {
directory: string
strategy?: string
}>
}
export type ProjectDirectories = Array<ProjectDirectory>
export type PtyTicketConnectToken = {
ticket: string
@ -4096,7 +4142,6 @@ export type SessionMessageUser = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
system?: string
type: "user"
}
@ -4357,6 +4402,26 @@ export type SessionNextRenamed = {
}
}
export type SessionNextForked = {
id: string
metadata?: {
[key: string]: unknown
}
type: "session.next.forked"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
timestamp: number
sessionID: string
parentID: string
messageID?: string
}
}
export type SessionNextPrompted = {
id: string
metadata?: {
@ -5115,6 +5180,11 @@ export type IntegrationAttemptStatus =
}
}
export type ProjectCurrent = {
id: string
directory: string
}
export type PermissionV2Request = {
id: string
sessionID: string
@ -5224,6 +5294,23 @@ export type CatalogUpdated = {
}
}
export type AgentUpdated = {
id: string
metadata?: {
[key: string]: unknown
}
type: "agent.updated"
durable?: {
aggregateID: string
seq: number
version: number
}
location?: LocationRef
data: {
[key: string]: unknown
}
}
export type SessionCreated = {
id: string
metadata?: {
@ -6371,6 +6458,14 @@ export type EventCatalogUpdated = {
}
}
export type EventAgentUpdated = {
id: string
type: "agent.updated"
properties: {
[key: string]: unknown
}
}
export type EventSessionCreated = {
id: string
type: "session.created"
@ -6479,6 +6574,17 @@ export type EventSessionNextRenamed = {
}
}
export type EventSessionNextForked = {
id: string
type: "session.next.forked"
properties: {
timestamp: number
sessionID: string
parentID: string
messageID?: string
}
}
export type EventSessionNextPrompted = {
id: string
type: "session.next.prompted"
@ -11700,6 +11806,45 @@ export type V2SessionGetResponses = {
export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses]
export type V2SessionForkData = {
body: {
messageID?: string
}
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/fork"
}
export type V2SessionForkErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError | MessageNotFoundError
*/
404: MessageNotFoundError | SessionNotFoundError
}
export type V2SessionForkError = V2SessionForkErrors[keyof V2SessionForkErrors]
export type V2SessionForkResponses = {
/**
* Success
*/
200: {
data: SessionV2Info
}
}
export type V2SessionForkResponse = V2SessionForkResponses[keyof V2SessionForkResponses]
export type V2SessionSwitchAgentData = {
body: {
agent: string
@ -12353,6 +12498,47 @@ export type V2ModelListResponses = {
export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses]
export type V2GenerateTextData = {
body: {
prompt: string
model?: ModelRef
}
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/generate"
}
export type V2GenerateTextErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* ServiceUnavailableError
*/
503: ServiceUnavailableError
}
export type V2GenerateTextError = V2GenerateTextErrors[keyof V2GenerateTextErrors]
export type V2GenerateTextResponses = {
/**
* GenerateTextResponse
*/
200: GenerateTextResponse
}
export type V2GenerateTextResponse = V2GenerateTextResponses[keyof V2GenerateTextResponses]
export type V2ProviderListData = {
body?: never
path?: never
@ -12793,6 +12979,76 @@ export type V2CredentialUpdateResponses = {
export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses]
export type V2ProjectCurrentData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/project/current"
}
export type V2ProjectCurrentErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ProjectCurrentError = V2ProjectCurrentErrors[keyof V2ProjectCurrentErrors]
export type V2ProjectCurrentResponses = {
/**
* Project.Current
*/
200: ProjectCurrent
}
export type V2ProjectCurrentResponse = V2ProjectCurrentResponses[keyof V2ProjectCurrentResponses]
export type V2ProjectDirectoriesData = {
body?: never
path: {
projectID: string
}
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/project/{projectID}/directories"
}
export type V2ProjectDirectoriesErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2ProjectDirectoriesError = V2ProjectDirectoriesErrors[keyof V2ProjectDirectoriesErrors]
export type V2ProjectDirectoriesResponses = {
/**
* Project.Directories
*/
200: ProjectDirectories
}
export type V2ProjectDirectoriesResponse = V2ProjectDirectoriesResponses[keyof V2ProjectDirectoriesResponses]
export type V2PermissionRequestListData = {
body?: never
path?: never

View file

@ -158,6 +158,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.provider.refresh(event.location),
])
break
case "agent.updated":
void result.location.agent.refresh(event.location)
break
case "session.next.agent.switched":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)

View file

@ -170,9 +170,22 @@ export function Session() {
})
onCleanup(() => setEpilogue())
const messages = sessionMessages
const descendantSessionIDs = createMemo(() => {
if (session()?.parentID) return []
const sessions = data.session.list()
const childrenByParent = sessions.reduce((acc, item) => {
if (!item.parentID) return acc
acc.set(item.parentID, [...(acc.get(item.parentID) ?? []), item.id])
return acc
}, new Map<string, string[]>())
function collect(sessionID: string): string[] {
return (childrenByParent.get(sessionID) ?? []).flatMap((id) => [id, ...collect(id)])
}
return collect(route.sessionID)
})
const permissions = createMemo(() => {
if (session()?.parentID) return []
return data.session.permission.list(route.sessionID) ?? []
return [route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? [])
})
const questions = createMemo(() => {
if (session()?.parentID) return []
@ -227,6 +240,12 @@ export function Session() {
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
createEffect(
on(descendantSessionIDs, (sessionIDs) => {
void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID)))
}),
)
createEffect(() => {
const sessionID = route.sessionID
void (async () => {

View file

@ -356,6 +356,53 @@ test("refreshes effective catalog data after catalog updates", async () => {
}
})
test("refreshes agents after agent updates", async () => {
const events = createEventStream()
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/agent") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: requests === 1 ? "build" : "reviewer",
request: { headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
},
],
})
}, 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 wait(() => data.location.agent.list()?.[0]?.id === "build")
emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} })
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
} finally {
app.renderer.destroy()
}
})
test("refreshes references after updates", async () => {
const events = createEventStream()
let requests = 0