From fb884bb91e435ccf004591bdb993ca2939432daa Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 1 Jul 2026 14:37:27 -0400 Subject: [PATCH] feat: update session notices and skill reloads --- packages/client/src/generated/types.ts | 14 +++++ packages/core/src/filesystem/watcher.ts | 22 ++++--- packages/core/src/flag/flag.ts | 7 +-- packages/core/src/session.ts | 27 ++++++++- packages/core/src/session/message-updater.ts | 1 + packages/core/src/skill.ts | 52 +++++++++++++--- packages/core/test/filesystem/watcher.test.ts | 10 +--- packages/core/test/move-session.test.ts | 2 + packages/core/test/permission.test.ts | 2 + packages/core/test/session-compact.test.ts | 2 + packages/core/test/session-create.test.ts | 10 ++-- packages/core/test/session-history.test.ts | 2 + packages/core/test/session-prompt.test.ts | 8 ++- .../core/test/session-runner-recorded.test.ts | 2 + packages/core/test/session-runner.test.ts | 2 + packages/core/test/session-wait.test.ts | 2 + packages/core/test/skill.test.ts | 56 ++++++++++++++++- packages/desktop/src/main/server.ts | 1 - packages/desktop/src/main/wsl/sidecar.ts | 2 +- packages/schema/src/event-manifest.ts | 2 + packages/schema/src/session-event.ts | 1 + packages/schema/src/session-message.ts | 1 + packages/schema/src/skill.ts | 4 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 39 ++++++++++++ packages/server/src/handlers/session.ts | 30 +--------- packages/tui/src/context/data.tsx | 4 ++ packages/tui/src/routes/session/index.tsx | 9 +-- packages/tui/src/routes/session/rows.ts | 5 +- .../tui/test/cli/tui/session-rows.test.ts | 60 +++++++++++++++++++ 29 files changed, 304 insertions(+), 75 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 21ee2cffdd..4873a9c455 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -664,6 +664,7 @@ export type SessionContextOutput = { readonly time: { readonly created: number } readonly sessionID: string readonly text: string + readonly description?: string readonly type: "synthetic" } | { @@ -932,6 +933,7 @@ export type SessionHistoryOutput = { readonly sessionID: string readonly messageID: string readonly text: string + readonly description?: string } } | { @@ -1425,6 +1427,7 @@ export type SessionEventsOutput = readonly sessionID: string readonly messageID: string readonly text: string + readonly description?: string } } | { @@ -1824,6 +1827,7 @@ export type SessionMessageOutput = { readonly time: { readonly created: number } readonly sessionID: string readonly text: string + readonly description?: string readonly type: "synthetic" } | { @@ -2004,6 +2008,7 @@ export type MessageListOutput = { readonly time: { readonly created: number } readonly sessionID: string readonly text: string + readonly description?: string readonly type: "synthetic" } | { @@ -3523,6 +3528,7 @@ export type EventSubscribeOutput = readonly sessionID: string readonly messageID: string readonly text: string + readonly description?: string } } | { @@ -3983,6 +3989,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "skill.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 6d4bb1c326..e4f3bce285 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -6,6 +6,7 @@ import type ParcelWatcher from "@parcel/watcher" import { makeLocationNode } from "../effect/app-node" import { Cause, Context, Effect, Layer } from "effect" import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" +import os from "os" import path from "path" import { Config } from "../config" import { EventV2 } from "../event" @@ -57,10 +58,14 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({}) + if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({}) const backend = getBackend() const location = yield* Location.Service + if (path.resolve(location.directory) === path.resolve(os.homedir())) { + yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory }) + return Service.of({}) + } if (!backend) { yield* Effect.logError("watcher backend not supported", { directory: location.directory, @@ -84,6 +89,7 @@ export const layer = Layer.effect( ) const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { + if (_error) runFork(Effect.logError("watcher callback failed", { error: _error })) for (const update of updates) { if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" })) @@ -94,7 +100,11 @@ export const layer = Layer.effect( const subscribe = (directory: string, ignore: string[]) => { const pending = w.subscribe(directory, callback, { ignore, backend }) return Effect.promise(() => pending).pipe( - Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))), + Effect.tap((subscription) => + Effect.sync(() => subscriptions.push(subscription)).pipe( + Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })), + ), + ), Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.catchCause((cause) => { pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) @@ -106,11 +116,9 @@ export const layer = Layer.effect( const config = (yield* (yield* Config.Service).entries()) .filter((entry): entry is Config.Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) - if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* Effect.forkScoped( - subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), - ) - } + yield* Effect.forkScoped( + subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), + ) if (location.vcs?.type === "git") { const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e..1c975040dc 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -32,14 +32,9 @@ export const Flag = { OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"), + OPENCODE_DISABLE_FILEWATCHER: truthy("OPENCODE_DISABLE_FILEWATCHER"), // Experimental - OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe( - Config.withDefault(false), - ), - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( - Config.withDefault(false), - ), OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 252f9ca0b2..d5e139bc59 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -39,6 +39,7 @@ import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" import { SkillV2 } from "./skill" +import { Job } from "./job" export const RevertState = Revert.State export type RevertState = Revert.State @@ -191,9 +192,14 @@ export interface Interface { ) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly active: Effect.Effect> + readonly background: (sessionID: SessionSchema.ID) => Effect.Effect readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect - readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect + readonly synthetic: (input: { + sessionID: SessionSchema.ID + text: string + description?: string + }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID @@ -217,6 +223,7 @@ export const layer = Layer.effect( const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service + const jobs = yield* Job.Service const scope = yield* Scope.Scope const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) @@ -512,6 +519,22 @@ export const layer = Layer.effect( yield* execution.awaitIdle(sessionID) }), active: execution.active, + background: Effect.fn("V2Session.background")(function* (sessionID) { + yield* result.get(sessionID) + const backgrounded = yield* jobs.backgroundAll({ sessionID }) + if (backgrounded.length === 0) return + yield* result.synthetic({ + sessionID, + text: [ + "User requested that active blocking work be moved to the background.", + "", + "Backgrounded work:", + ...backgrounded.map((job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`), + "", + "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", + ].join("\n"), + }) + }), resume: Effect.fn("V2Session.resume")(function* (sessionID) { yield* result.get(sessionID) yield* execution.resume(sessionID) @@ -523,6 +546,7 @@ export const layer = Layer.effect( messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: input.text, + description: input.description, }) yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), @@ -587,6 +611,7 @@ export const node = makeGlobalNode({ service: Service, layer: layer.pipe(Layer.orDie), deps: [ + Job.node, Database.node, EventV2.node, ProjectV2.node, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 1e12498047..5b63031c95 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -153,6 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.Synthetic.make({ sessionID: event.data.sessionID, text: event.data.text, + description: event.data.description, id: event.data.messageID, type: "synthetic", time: { created: event.data.timestamp }, diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index cb8ce271c5..eeafe60885 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -2,10 +2,12 @@ export * as SkillV2 from "./skill" import { makeLocationNode } from "./effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema, Types } from "effect" +import { Context, Effect, Layer, Schema, Stream, Types } from "effect" +import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { Skill } from "@opencode-ai/schema/skill" import { AgentV2 } from "./agent" import { ConfigMarkdown } from "./config/markdown" +import { EventV2 } from "./event" import { FSUtil } from "./fs-util" import { PermissionV2 } from "./permission" import { AbsolutePath } from "./schema" @@ -27,6 +29,8 @@ export type Source = typeof Source.Type export const Info = Skill.Info export type Info = Skill.Info +export const Event = Skill.Event + export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") @@ -58,6 +62,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const discovery = yield* SkillDiscovery.Service const fs = yield* FSUtil.Service + const events = yield* EventV2.Service const state = State.create({ initial: () => ({ sources: [] }), @@ -72,7 +77,15 @@ export const layer = Layer.effect( const load = Effect.fn("SkillV2.load")(function* (source: Source) { const skills: Info[] = [] - if (source.type === "embedded") return [source.skill] + if (source.type === "embedded") { + yield* Effect.logDebug("skill source loaded", { + source: Source.key(source), + type: source.type, + directories: [], + skills: [source.skill.name], + }) + return { skills: [source.skill], directories: [] } + } const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url) for (const directory of directories) { const files = yield* fs @@ -101,19 +114,42 @@ export const layer = Layer.effect( }) } } - return skills + yield* Effect.logDebug("skill source loaded", { + source: Source.key(source), + type: source.type, + directories, + skills: skills.map((skill) => skill.name), + }) + return { skills, directories } }) - // QUESTION(Dax): Should local skill sources invalidate on filesystem watch - // events, following the reload policy chosen for other context sources? - const cache = new Map() + const cache = new Map() + const invalidate = Effect.fn("SkillV2.invalidateFromWatcher")(function* (file: string) { + const invalidated = Array.from(cache.entries()).filter(([, loaded]) => + loaded.directories.some((directory) => FSUtil.contains(directory, file)), + ) + if (invalidated.length === 0) return + for (const [key] of invalidated) cache.delete(key) + yield* Effect.logInfo("skill cache invalidated", { + file, + sources: invalidated.map(([key]) => key), + skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)), + }) + yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) + }) + + yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe( + Stream.runForEach((event) => invalidate(event.data.file)), + Effect.forkScoped({ startImmediately: true }), + ) + const list = Effect.fn("SkillV2.list")(function* () { const skills = new Map() for (const source of state.get().sources) { const key = Source.key(source) const loaded = cache.get(key) ?? (yield* load(source)) cache.set(key, loaded) - for (const skill of loaded) skills.set(skill.name, skill) + for (const skill of loaded.skills) skills.set(skill.name, skill) } return Array.from(skills.values()) }) @@ -131,4 +167,4 @@ export const layer = Layer.effect( export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) -export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index a189442e8b..118f099dad 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -2,7 +2,7 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" +import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" import { Config } from "@opencode-ai/core/config" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -27,13 +27,6 @@ const configLayer = Layer.succeed( }), ) -const flagsLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", - OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false", - }), -) - function provide(directory: string, vcs?: Location.Interface["vcs"]) { const locationLayer = Layer.succeed( Location.Service, @@ -44,7 +37,6 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Layer.provide(configLayer), Layer.provide(Git.defaultLayer), Layer.provide(locationLayer), - Layer.provide(flagsLayer), ), ) } diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 07f1459335..b70bcd8f40 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -9,6 +9,7 @@ import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Git } from "@opencode-ai/core/git" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" @@ -29,6 +30,7 @@ const project = Project.layer.pipe( Layer.provide(ProjectDirectories.defaultLayer), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index dca9f8ead4..b63bdef888 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -3,6 +3,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionTable } from "@opencode-ai/core/permission/sql" @@ -24,6 +25,7 @@ const current = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index a877624906..35db267d8a 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -4,6 +4,7 @@ import { OpenAIChat } from "@opencode-ai/llm/protocols" import { Config } from "@opencode-ai/core/config" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationServices } from "@opencode-ai/core/location-services" @@ -60,6 +61,7 @@ const locations = Layer.effect( ), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locations), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index aaff515d12..aff3fa1b0e 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -6,6 +6,7 @@ import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" +import { Job } from "@opencode-ai/core/job" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" @@ -36,6 +37,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), @@ -182,11 +184,9 @@ describe("SessionV2.create", () => { expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" }) - expect((yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq)).toEqual([ - 0, - 4, - 5, - ]) + expect( + (yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq), + ).toEqual([0, 4, 5]) expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id }) }), ) diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 9fbfb786d3..1323ed5fc5 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { Location } from "@opencode-ai/core/location" import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { ProjectV2 } from "@opencode-ai/core/project" @@ -23,6 +24,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 666e9d2fde..86558f728a 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" +import { Job } from "@opencode-ai/core/job" import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -45,6 +46,7 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), @@ -114,7 +116,11 @@ const eventCount = (type: string) => const encodeMessage = Schema.encodeSync(SessionMessage.Message) const assistantRow = (id: SessionMessage.ID, seq: number) => { - const { id: _, type, ...data } = encodeMessage( + const { + id: _, + type, + ...data + } = encodeMessage( SessionMessage.Assistant.make({ id, type: "assistant", diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 82be33fcc7..74f325207c 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -5,6 +5,7 @@ import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" +import { Job } from "@opencode-ai/core/job" import { PermissionV2 } from "@opencode-ai/core/permission" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" @@ -111,6 +112,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 23c2e5099d..5ba16da802 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -12,6 +12,7 @@ import { import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -266,6 +267,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/session-wait.test.ts b/packages/core/test/session-wait.test.ts index cd8399a534..b2d0b545f9 100644 --- a/packages/core/test/session-wait.test.ts +++ b/packages/core/test/session-wait.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" +import { Job } from "@opencode-ai/core/job" import { Location } from "@opencode-ai/core/location" import { ProjectV2 } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -21,6 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, { awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)), }) const sessions = SessionV2.layer.pipe( + Layer.provide(Job.layer), Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 94196fd3bd..7b662b388d 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -1,14 +1,16 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Fiber, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -24,7 +26,7 @@ const discovery = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node]), [ + AppNodeBuilder.build(LayerNode.group([SkillV2.node, AgentV2.node, EventV2.node]), [ LayerNode.replace(SkillDiscovery.layer, discovery), ]), ) @@ -40,6 +42,19 @@ description: ${description} ) } +function waitForSkillUpdate() { + return Effect.gen(function* () { + const events = yield* EventV2.Service + const deferred = yield* Deferred.make() + const fiber = yield* events.subscribe(SkillV2.Event.Updated).pipe( + Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)), + Effect.forkScoped, + ) + yield* Effect.yieldNow + return { deferred, fiber } + }) +} + describe("SkillV2", () => { it.live("registers sources and resolves later source precedence", () => Effect.acquireRelease( @@ -124,4 +139,41 @@ describe("SkillV2", () => { ), ), ) + + it.live("invalidates cached skills and publishes updates for watcher changes", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true }) + await write(tmp.path, "deploy", "Initial deploy") + }) + + const events = yield* EventV2.Service + const skill = yield* SkillV2.Service + yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) })) + + expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy") + + const file = path.join(tmp.path, "deploy", "SKILL.md") + yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy")) + expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy") + + yield* Effect.acquireUseRelease( + waitForSkillUpdate(), + ({ deferred }) => + events + .publish(FileSystemWatcher.Event.Updated, { file, event: "change" }) + .pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")), + ({ fiber }) => Fiber.interrupt(fiber), + ) + + expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy") + }), + ), + ), + ) }) diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index b213dbc82a..75ff502928 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -46,7 +46,6 @@ export function preferAppEnv(userDataPath: string) { Object.assign(process.env, { ...(shell ? loadShellEnv(shell, getLogger()) : null), OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", - OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts index 522e575a47..850b649479 100644 --- a/packages/desktop/src/main/wsl/sidecar.ts +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -29,7 +29,7 @@ export async function spawnWslSidecar( 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")', "export PATH", "export WSLENV=", - "export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true", + "export OPENCODE_DISABLE_FILEWATCHER=true", "export OPENCODE_CLIENT=desktop", `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 1adfba0290..5e8a2871f3 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -23,6 +23,7 @@ import { QuestionV1 } from "./question-v1.js" import { Reference } from "./reference.js" import { ServerEvent } from "./server-event.js" import { Shell } from "./shell.js" +import { Skill } from "./skill.js" import { SessionCompactionEvent } from "./session-compaction-event.js" import { SessionEvent } from "./session-event.js" import { SessionStatusEvent } from "./session-status-event.js" @@ -52,6 +53,7 @@ const featureDefinitions = Event.inventory( ...Permission.Event.Definitions, ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, + ...Skill.Event.Definitions, ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, ...Shell.Event.Definitions, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index ae955f4769..fde5dd6014 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -137,6 +137,7 @@ export const Synthetic = Event.define({ ...Base, messageID: SessionMessage.ID, text: Schema.String, + description: Schema.String.pipe(optional), }, }) export type Synthetic = typeof Synthetic.Type diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 318dbfa9c7..457cf87206 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -55,6 +55,7 @@ export const Synthetic = Schema.Struct({ ...Base, sessionID: SessionID, text: Schema.String, + description: Schema.String.pipe(optional), type: Schema.Literal("synthetic"), }).annotate({ identifier: "Session.Message.Synthetic" }) diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index ec654bff21..30099b7a90 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -3,6 +3,7 @@ export * as Skill from "./skill.js" import { Schema } from "effect" import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" +import { define, inventory } from "./event.js" export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ @@ -25,6 +26,9 @@ export const Info = Schema.Struct({ content: Schema.String, }).annotate({ identifier: "SkillV2.Info" }) +const Updated = define({ type: "skill.updated", schema: {} }) +export const Event = { Updated, Definitions: inventory(Updated) } + export interface EmbeddedSource extends Schema.Schema.Type {} export const EmbeddedSource = Schema.Struct({ type: Schema.Literal("embedded"), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 938f916ca2..7da625990d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -63,6 +63,7 @@ export type Event = | EventPermissionV2Replied | EventPluginAdded | EventProjectDirectoriesUpdated + | EventSkillUpdated | EventFileWatcherUpdated | EventPtyCreated | EventPtyUpdated @@ -940,6 +941,7 @@ export type GlobalEvent = { sessionID: string messageID: string text: string + description?: string } } | { @@ -1354,6 +1356,13 @@ export type GlobalEvent = { projectID: string } } + | { + id: string + type: "skill.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "file.watcher.updated" @@ -3037,6 +3046,7 @@ export type V2Event = | PermissionV2Replied | PluginAdded | ProjectDirectoriesUpdated + | SkillUpdated | FileWatcherUpdated | PtyCreated | PtyUpdated @@ -3607,6 +3617,7 @@ export type SyncEventSessionNextSynthetic = { sessionID: string messageID: string text: string + description?: string } } } @@ -4210,6 +4221,7 @@ export type SessionMessageSynthetic = { } sessionID: string text: string + description?: string type: "synthetic" } @@ -4570,6 +4582,7 @@ export type SessionNextSynthetic = { sessionID: string messageID: string text: string + description?: string } } @@ -5861,6 +5874,23 @@ export type ProjectDirectoriesUpdated = { } } +export type SkillUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "skill.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type FileWatcherUpdated = { id: string metadata?: { @@ -6774,6 +6804,7 @@ export type EventSessionNextSynthetic = { sessionID: string messageID: string text: string + description?: string } } @@ -7226,6 +7257,14 @@ export type EventProjectDirectoriesUpdated = { } } +export type EventSkillUpdated = { + id: string + type: "skill.updated" + properties: { + [key: string]: unknown + } +} + export type EventFileWatcherUpdated = { id: string type: "file.watcher.updated" diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index c41374522b..15788fd6ec 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -3,7 +3,6 @@ import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" -import { Job } from "@opencode-ai/core/job" import { ConflictError, InvalidCursorError, @@ -22,7 +21,6 @@ const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service - const jobs = yield* Job.Service return handlers .handle( @@ -486,7 +484,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl .handle( "session.background", Effect.fn(function* (ctx) { - yield* session.get(ctx.params.sessionID).pipe( + yield* session.background(ctx.params.sessionID).pipe( Effect.catchTag("Session.NotFoundError", (error) => Effect.fail( new SessionNotFoundError({ @@ -496,32 +494,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl ), ), ) - const backgrounded = yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID }) - if (backgrounded.length > 0) - yield* session - .synthetic({ - sessionID: ctx.params.sessionID, - text: [ - "User requested that active blocking work be moved to the background.", - "", - "Backgrounded work:", - ...backgrounded.map( - (job) => `- ${job.type}: ${job.title && job.title.length > 0 ? job.title : job.id}`, - ), - "", - "The backgrounded work is still unfinished. Move on to other work if you can. If there is nothing else useful to do, finish your response. Do not wait, sleep, poll, or report the backgrounded work as complete until a later completion notification is added to the conversation.", - ].join("\n"), - }) - .pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), - ), - ), - ) return HttpApiSchema.NoContent.make() }), ) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 1589e93f92..f8e8993142 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -163,6 +163,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "agent.updated": void result.location.agent.refresh(event.location) break + case "skill.updated": + void result.location.skill.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) @@ -248,6 +251,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "synthetic", sessionID: event.data.sessionID, text: event.data.text, + description: event.data.description, time: { created: event.data.timestamp }, }) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 459c48553b..17c4d76cb4 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1250,13 +1250,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { function SessionNoticeMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() const text = () => { - if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text + if (props.message.type === "system") return "Instructions updated" + if (props.message.type === "synthetic") return props.message.description ?? "" return "" } return ( - - {text()} - + + {text()} + ) } diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 3b21eec0cf..b489404f4f 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -119,7 +119,9 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.next.prompt.admitted", message), data.on("session.next.prompted", message), data.on("session.next.context.updated", message), - data.on("session.next.synthetic", message), + data.on("session.next.synthetic", (event) => { + if (event.data.sessionID === sessionID() && event.data.description?.trim()) appendMessage(event.data.messageID) + }), data.on("session.next.shell.started", message), data.on("session.next.agent.switched", message), data.on("session.next.model.switched", message), @@ -160,6 +162,7 @@ export function createSessionRows(sessionID: Accessor) { export function reduceSessionRows(messages: SessionMessage[]) { return messages.reduce((rows, message) => { if (message.type !== "assistant") { + if (message.type === "synthetic" && !message.description?.trim()) return rows completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index d6a804625f..fec6f5eb87 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -122,6 +122,66 @@ test("completes exploration groups when another row follows", () => { ]) }) +test("hides synthetic messages without descriptions", () => { + const messages: SessionMessage[] = [ + assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), + { + type: "synthetic", + id: "synthetic-1", + sessionID: "session-1", + text: "internal context", + time: { created: 2 }, + }, + assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "exploration", + pending: [], + completed: false, + refs: [ + { messageID: "assistant-1", partID: "read-1" }, + { messageID: "assistant-2", partID: "grep-1" }, + ], + }, + ]) +}) + +test("renders synthetic messages with descriptions", () => { + const messages: SessionMessage[] = [ + assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), + { + type: "synthetic", + id: "synthetic-1", + sessionID: "session-1", + text: "internal context", + description: "Explicit notice", + time: { created: 2 }, + }, + assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "exploration", + pending: [], + completed: true, + refs: [{ messageID: "assistant-1", partID: "read-1" }], + }, + { type: "message", messageID: "synthetic-1" }, + { + type: "group", + kind: "exploration", + pending: [], + completed: false, + refs: [{ messageID: "assistant-2", partID: "grep-1" }], + }, + ]) +}) + function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant { return { type: "assistant",