From 02cb35088012cc2b994dd8df8cf6d8518f5ff5e0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 18:52:00 -0400 Subject: [PATCH] feat(tui): refresh agents after update events --- packages/core/src/agent.ts | 7 +- packages/core/src/config/plugin/agent.ts | 1 + packages/core/src/config/plugin/external.ts | 4 +- packages/core/src/plugin/internal.ts | 2 +- packages/core/test/agent.test.ts | 28 +- packages/core/test/config/agent.test.ts | 5 +- .../core/test/session-runner-recorded.test.ts | 2 +- packages/core/test/session-runner.test.ts | 2 +- packages/opencode/test/event-manifest.test.ts | 4 +- packages/schema/src/agent.ts | 19 +- packages/schema/src/event-manifest.ts | 2 + packages/schema/test/event-manifest.test.ts | 23 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 156 ++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 280 +++++++++++++++++- packages/tui/src/context/data.tsx | 3 + packages/tui/src/routes/session/index.tsx | 21 +- packages/tui/test/cli/tui/data.test.tsx | 47 +++ 17 files changed, 576 insertions(+), 30 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ebdecb5c4c..3d5cc3b0c0 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -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()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const events = yield* EventV2.Service const state = State.create({ 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] }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..1dbff023f4 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -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)) } diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d8ace63b3d..b5163329c9 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -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 })) + }) }), }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d1262372a9..3a4d0092c4 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -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. diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 0e0b05803f..4bba451895 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -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 diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index ea553671bd..7085f65cd7 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -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", () => { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index ac87c0e366..fd819f9f7a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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" }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 94c631ac86..aa9dfd63f6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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({ diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index da0f80cee5..a38d96cb83 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -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) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index adf7aec282..9313ece4ef 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -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), +} diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index e47bc6ed90..cbd9d86819 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -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, ) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5694afdd30..230eb93be4 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 80aec1244c..0a261778bf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( + parameters: { + sessionID: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + 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( + parameters?: { + location?: { + directory?: string + workspace?: string + } + prompt?: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "prompt" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + 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( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/project/current", + ...options, + ...params, + }) + } + + /** + * List project directories + * + * List known local absolute directories for a project. + */ + public directories( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + 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 })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9259dad941..3902f04c99 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 agents?: Array - 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 agents?: Array - 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 export type PtyTicketConnectToken = { ticket: string @@ -4096,7 +4142,6 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array - 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 diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ae56634a22..12ba3a7f3a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 95ec719d59..b7ff581709 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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()) + 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 () => { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 50f5306c21..e8750e79f5 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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