feat: update session notices and skill reloads

This commit is contained in:
Dax Raad 2026-07-01 14:37:27 -04:00
commit fb884bb91e
29 changed files with 304 additions and 75 deletions

View file

@ -664,6 +664,7 @@ export type SessionContextOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly sessionID: string readonly sessionID: string
readonly text: string readonly text: string
readonly description?: string
readonly type: "synthetic" readonly type: "synthetic"
} }
| { | {
@ -932,6 +933,7 @@ export type SessionHistoryOutput = {
readonly sessionID: string readonly sessionID: string
readonly messageID: string readonly messageID: string
readonly text: string readonly text: string
readonly description?: string
} }
} }
| { | {
@ -1425,6 +1427,7 @@ export type SessionEventsOutput =
readonly sessionID: string readonly sessionID: string
readonly messageID: string readonly messageID: string
readonly text: string readonly text: string
readonly description?: string
} }
} }
| { | {
@ -1824,6 +1827,7 @@ export type SessionMessageOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly sessionID: string readonly sessionID: string
readonly text: string readonly text: string
readonly description?: string
readonly type: "synthetic" readonly type: "synthetic"
} }
| { | {
@ -2004,6 +2008,7 @@ export type MessageListOutput = {
readonly time: { readonly created: number } readonly time: { readonly created: number }
readonly sessionID: string readonly sessionID: string
readonly text: string readonly text: string
readonly description?: string
readonly type: "synthetic" readonly type: "synthetic"
} }
| { | {
@ -3523,6 +3528,7 @@ export type EventSubscribeOutput =
readonly sessionID: string readonly sessionID: string
readonly messageID: string readonly messageID: string
readonly text: string readonly text: string
readonly description?: string
} }
} }
| { | {
@ -3983,6 +3989,14 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly projectID: 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 id: string
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }

View file

@ -6,6 +6,7 @@ import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import os from "os"
import path from "path" import path from "path"
import { Config } from "../config" import { Config } from "../config"
import { EventV2 } from "../event" import { EventV2 } from "../event"
@ -57,10 +58,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { 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 backend = getBackend()
const location = yield* Location.Service 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) { if (!backend) {
yield* Effect.logError("watcher backend not supported", { yield* Effect.logError("watcher backend not supported", {
directory: location.directory, directory: location.directory,
@ -84,6 +89,7 @@ export const layer = Layer.effect(
) )
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => { const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
for (const update of updates) { for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" })) 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" })) 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 subscribe = (directory: string, ignore: string[]) => {
const pending = w.subscribe(directory, callback, { ignore, backend }) const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe( 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.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
@ -106,11 +116,9 @@ export const layer = Layer.effect(
const config = (yield* (yield* Config.Service).entries()) const config = (yield* (yield* Config.Service).entries())
.filter((entry): entry is Config.Document => entry.type === "document") .filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? []) .flatMap((item) => item.info.watcher?.ignore ?? [])
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { yield* Effect.forkScoped(
yield* Effect.forkScoped( subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]), )
)
}
if (location.vcs?.type === "git") { if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory

View file

@ -32,14 +32,9 @@ export const Flag = {
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"), OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
OPENCODE_DISABLE_FILEWATCHER: truthy("OPENCODE_DISABLE_FILEWATCHER"),
// Experimental // 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: OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT:
copy === undefined ? process.platform === "win32" : truthy("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"], OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],

View file

@ -39,6 +39,7 @@ import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
import { SkillV2 } from "./skill" import { SkillV2 } from "./skill"
import { Job } from "./job"
export const RevertState = Revert.State export const RevertState = Revert.State
export type RevertState = Revert.State export type RevertState = Revert.State
@ -191,9 +192,14 @@ export interface Interface {
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError> ) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>> readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect<void, NotFoundError> readonly synthetic: (input: {
sessionID: SessionSchema.ID
text: string
description?: string
}) => Effect.Effect<void, NotFoundError>
readonly revert: { readonly revert: {
readonly stage: (input: { readonly stage: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@ -217,6 +223,7 @@ export const layer = Layer.effect(
const execution = yield* SessionExecution.Service const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const jobs = yield* Job.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@ -512,6 +519,22 @@ export const layer = Layer.effect(
yield* execution.awaitIdle(sessionID) yield* execution.awaitIdle(sessionID)
}), }),
active: execution.active, 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) { resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID) yield* result.get(sessionID)
yield* execution.resume(sessionID) yield* execution.resume(sessionID)
@ -523,6 +546,7 @@ export const layer = Layer.effect(
messageID: SessionMessage.ID.create(), messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now, timestamp: yield* DateTime.now,
text: input.text, text: input.text,
description: input.description,
}) })
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) 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, service: Service,
layer: layer.pipe(Layer.orDie), layer: layer.pipe(Layer.orDie),
deps: [ deps: [
Job.node,
Database.node, Database.node,
EventV2.node, EventV2.node,
ProjectV2.node, ProjectV2.node,

View file

@ -153,6 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.Synthetic.make({ SessionMessage.Synthetic.make({
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text: event.data.text, text: event.data.text,
description: event.data.description,
id: event.data.messageID, id: event.data.messageID,
type: "synthetic", type: "synthetic",
time: { created: event.data.timestamp }, time: { created: event.data.timestamp },

View file

@ -2,10 +2,12 @@ export * as SkillV2 from "./skill"
import { makeLocationNode } from "./effect/app-node" import { makeLocationNode } from "./effect/app-node"
import path from "path" 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 { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent" import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown" import { ConfigMarkdown } from "./config/markdown"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { PermissionV2 } from "./permission" import { PermissionV2 } from "./permission"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
@ -27,6 +29,8 @@ export type Source = typeof Source.Type
export const Info = Skill.Info export const Info = Skill.Info
export type Info = Skill.Info export type Info = Skill.Info
export const Event = Skill.Event
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) => export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
@ -58,6 +62,7 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const discovery = yield* SkillDiscovery.Service const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const events = yield* EventV2.Service
const state = State.create<Data, Draft>({ const state = State.create<Data, Draft>({
initial: () => ({ sources: [] }), initial: () => ({ sources: [] }),
@ -72,7 +77,15 @@ export const layer = Layer.effect(
const load = Effect.fn("SkillV2.load")(function* (source: Source) { const load = Effect.fn("SkillV2.load")(function* (source: Source) {
const skills: Info[] = [] 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) const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
for (const directory of directories) { for (const directory of directories) {
const files = yield* fs 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 const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
// events, following the reload policy chosen for other context sources? const invalidate = Effect.fn("SkillV2.invalidateFromWatcher")(function* (file: string) {
const cache = new Map<string, Info[]>() 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 list = Effect.fn("SkillV2.list")(function* () {
const skills = new Map<string, Info>() const skills = new Map<string, Info>()
for (const source of state.get().sources) { for (const source of state.get().sources) {
const key = Source.key(source) const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source)) const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded) 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()) 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 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] })

View file

@ -2,7 +2,7 @@ import { $ } from "bun"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" 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 { Config } from "@opencode-ai/core/config"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util" 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"]) { function provide(directory: string, vcs?: Location.Interface["vcs"]) {
const locationLayer = Layer.succeed( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
@ -44,7 +37,6 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Layer.provide(configLayer), Layer.provide(configLayer),
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(locationLayer), Layer.provide(locationLayer),
Layer.provide(flagsLayer),
), ),
) )
} }

View file

@ -9,6 +9,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git" import { Git } from "@opencode-ai/core/git"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { ProjectDirectories } from "@opencode-ai/core/project/directories"
@ -29,6 +30,7 @@ const project = Project.layer.pipe(
Layer.provide(ProjectDirectories.defaultLayer), Layer.provide(ProjectDirectories.defaultLayer),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),

View file

@ -3,6 +3,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql" import { PermissionTable } from "@opencode-ai/core/permission/sql"
@ -24,6 +25,7 @@ const current = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("/project") })), Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -4,6 +4,7 @@ import { OpenAIChat } from "@opencode-ai/llm/protocols"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services" import type { LocationServices } from "@opencode-ai/core/location-services"
@ -60,6 +61,7 @@ const locations = Layer.effect(
), ),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locations), Layer.provide(locations),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -6,6 +6,7 @@ import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
@ -36,6 +37,7 @@ const projects = Layer.succeed(
}), }),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.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(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)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" }) 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([ expect(
0, (yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq),
4, ).toEqual([0, 4, 5])
5,
])
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id }) expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
}), }),
) )

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
@ -23,6 +24,7 @@ const projects = Layer.succeed(
}), }),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Job } from "@opencode-ai/core/job"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
@ -45,6 +46,7 @@ const execution = Layer.succeed(
}), }),
) )
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),
@ -114,7 +116,11 @@ const eventCount = (type: string) =>
const encodeMessage = Schema.encodeSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (id: SessionMessage.ID, seq: number) => { const assistantRow = (id: SessionMessage.ID, seq: number) => {
const { id: _, type, ...data } = encodeMessage( const {
id: _,
type,
...data
} = encodeMessage(
SessionMessage.Assistant.make({ SessionMessage.Assistant.make({
id, id,
type: "assistant", type: "assistant",

View file

@ -5,6 +5,7 @@ import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Job } from "@opencode-ai/core/job"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
@ -111,6 +112,7 @@ const execution = Layer.effect(
}), }),
).pipe(Layer.provide(runner)) ).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -12,6 +12,7 @@ import {
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
@ -266,6 +267,7 @@ const execution = Layer.effect(
}), }),
).pipe(Layer.provide(runner)) ).pipe(Layer.provide(runner))
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@ -21,6 +22,7 @@ const execution = Layer.mock(SessionExecution.Service, {
awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)), awaitIdle: (sessionID) => Effect.sync(() => awaited.push(sessionID)),
}) })
const sessions = SessionV2.layer.pipe( const sessions = SessionV2.layer.pipe(
Layer.provide(Job.layer),
Layer.provide(locationServiceMapLayer), Layer.provide(locationServiceMapLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer), Layer.provide(Database.defaultLayer),

View file

@ -1,14 +1,16 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" 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 { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -24,7 +26,7 @@ const discovery = Layer.succeed(
}), }),
) )
const it = testEffect( 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), 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<void>()
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", () => { describe("SkillV2", () => {
it.live("registers sources and resolves later source precedence", () => it.live("registers sources and resolves later source precedence", () =>
Effect.acquireRelease( 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")
}),
),
),
)
}) })

View file

@ -46,7 +46,6 @@ export function preferAppEnv(userDataPath: string) {
Object.assign(process.env, { Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null), ...(shell ? loadShellEnv(shell, getLogger()) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop", OPENCODE_CLIENT: "desktop",
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
}) })

View file

@ -29,7 +29,7 @@ export async function spawnWslSidecar(
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")', 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
"export PATH", "export PATH",
"export WSLENV=", "export WSLENV=",
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true", "export OPENCODE_DISABLE_FILEWATCHER=true",
"export OPENCODE_CLIENT=desktop", "export OPENCODE_CLIENT=desktop",
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`, `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`, `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,

View file

@ -23,6 +23,7 @@ import { QuestionV1 } from "./question-v1.js"
import { Reference } from "./reference.js" import { Reference } from "./reference.js"
import { ServerEvent } from "./server-event.js" import { ServerEvent } from "./server-event.js"
import { Shell } from "./shell.js" import { Shell } from "./shell.js"
import { Skill } from "./skill.js"
import { SessionCompactionEvent } from "./session-compaction-event.js" import { SessionCompactionEvent } from "./session-compaction-event.js"
import { SessionEvent } from "./session-event.js" import { SessionEvent } from "./session-event.js"
import { SessionStatusEvent } from "./session-status-event.js" import { SessionStatusEvent } from "./session-status-event.js"
@ -52,6 +53,7 @@ const featureDefinitions = Event.inventory(
...Permission.Event.Definitions, ...Permission.Event.Definitions,
...Plugin.Event.Definitions, ...Plugin.Event.Definitions,
...ProjectDirectories.Event.Definitions, ...ProjectDirectories.Event.Definitions,
...Skill.Event.Definitions,
...FileSystemWatcher.Event.Definitions, ...FileSystemWatcher.Event.Definitions,
...Pty.Event.Definitions, ...Pty.Event.Definitions,
...Shell.Event.Definitions, ...Shell.Event.Definitions,

View file

@ -137,6 +137,7 @@ export const Synthetic = Event.define({
...Base, ...Base,
messageID: SessionMessage.ID, messageID: SessionMessage.ID,
text: Schema.String, text: Schema.String,
description: Schema.String.pipe(optional),
}, },
}) })
export type Synthetic = typeof Synthetic.Type export type Synthetic = typeof Synthetic.Type

View file

@ -55,6 +55,7 @@ export const Synthetic = Schema.Struct({
...Base, ...Base,
sessionID: SessionID, sessionID: SessionID,
text: Schema.String, text: Schema.String,
description: Schema.String.pipe(optional),
type: Schema.Literal("synthetic"), type: Schema.Literal("synthetic"),
}).annotate({ identifier: "Session.Message.Synthetic" }) }).annotate({ identifier: "Session.Message.Synthetic" })

View file

@ -3,6 +3,7 @@ export * as Skill from "./skill.js"
import { Schema } from "effect" import { Schema } from "effect"
import { optional } from "./schema.js" import { optional } from "./schema.js"
import { AbsolutePath } from "./schema.js" import { AbsolutePath } from "./schema.js"
import { define, inventory } from "./event.js"
export interface DirectorySource extends Schema.Schema.Type<typeof DirectorySource> {} export interface DirectorySource extends Schema.Schema.Type<typeof DirectorySource> {}
export const DirectorySource = Schema.Struct({ export const DirectorySource = Schema.Struct({
@ -25,6 +26,9 @@ export const Info = Schema.Struct({
content: Schema.String, content: Schema.String,
}).annotate({ identifier: "SkillV2.Info" }) }).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<typeof EmbeddedSource> {} export interface EmbeddedSource extends Schema.Schema.Type<typeof EmbeddedSource> {}
export const EmbeddedSource = Schema.Struct({ export const EmbeddedSource = Schema.Struct({
type: Schema.Literal("embedded"), type: Schema.Literal("embedded"),

View file

@ -63,6 +63,7 @@ export type Event =
| EventPermissionV2Replied | EventPermissionV2Replied
| EventPluginAdded | EventPluginAdded
| EventProjectDirectoriesUpdated | EventProjectDirectoriesUpdated
| EventSkillUpdated
| EventFileWatcherUpdated | EventFileWatcherUpdated
| EventPtyCreated | EventPtyCreated
| EventPtyUpdated | EventPtyUpdated
@ -940,6 +941,7 @@ export type GlobalEvent = {
sessionID: string sessionID: string
messageID: string messageID: string
text: string text: string
description?: string
} }
} }
| { | {
@ -1354,6 +1356,13 @@ export type GlobalEvent = {
projectID: string projectID: string
} }
} }
| {
id: string
type: "skill.updated"
properties: {
[key: string]: unknown
}
}
| { | {
id: string id: string
type: "file.watcher.updated" type: "file.watcher.updated"
@ -3037,6 +3046,7 @@ export type V2Event =
| PermissionV2Replied | PermissionV2Replied
| PluginAdded | PluginAdded
| ProjectDirectoriesUpdated | ProjectDirectoriesUpdated
| SkillUpdated
| FileWatcherUpdated | FileWatcherUpdated
| PtyCreated | PtyCreated
| PtyUpdated | PtyUpdated
@ -3607,6 +3617,7 @@ export type SyncEventSessionNextSynthetic = {
sessionID: string sessionID: string
messageID: string messageID: string
text: string text: string
description?: string
} }
} }
} }
@ -4210,6 +4221,7 @@ export type SessionMessageSynthetic = {
} }
sessionID: string sessionID: string
text: string text: string
description?: string
type: "synthetic" type: "synthetic"
} }
@ -4570,6 +4582,7 @@ export type SessionNextSynthetic = {
sessionID: string sessionID: string
messageID: string messageID: string
text: 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 = { export type FileWatcherUpdated = {
id: string id: string
metadata?: { metadata?: {
@ -6774,6 +6804,7 @@ export type EventSessionNextSynthetic = {
sessionID: string sessionID: string
messageID: string messageID: string
text: 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 = { export type EventFileWatcherUpdated = {
id: string id: string
type: "file.watcher.updated" type: "file.watcher.updated"

View file

@ -3,7 +3,6 @@ import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api" import { Api } from "../api"
import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
import { Job } from "@opencode-ai/core/job"
import { import {
ConflictError, ConflictError,
InvalidCursorError, InvalidCursorError,
@ -22,7 +21,6 @@ const DefaultSessionHistoryLimit = 50
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const jobs = yield* Job.Service
return handlers return handlers
.handle( .handle(
@ -486,7 +484,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle( .handle(
"session.background", "session.background",
Effect.fn(function* (ctx) { Effect.fn(function* (ctx) {
yield* session.get(ctx.params.sessionID).pipe( yield* session.background(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) => Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail( Effect.fail(
new SessionNotFoundError({ 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() return HttpApiSchema.NoContent.make()
}), }),
) )

View file

@ -163,6 +163,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
case "agent.updated": case "agent.updated":
void result.location.agent.refresh(event.location) void result.location.agent.refresh(event.location)
break break
case "skill.updated":
void result.location.skill.refresh(event.location)
break
case "session.next.agent.switched": case "session.next.agent.switched":
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent) setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
@ -248,6 +251,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "synthetic", type: "synthetic",
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
text: event.data.text, text: event.data.text,
description: event.data.description,
time: { created: event.data.timestamp }, time: { created: event.data.timestamp },
}) })
}) })

View file

@ -1250,13 +1250,14 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
function SessionNoticeMessageV2(props: { message: SessionMessage }) { function SessionNoticeMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme() const { theme } = useTheme()
const text = () => { 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 ""
} }
return ( return (
<box paddingLeft={3}> <InlineToolRow icon="◈" color={theme.textMuted} pending="Notice" complete={true}>
<text fg={theme.textMuted}>{text()}</text> {text()}
</box> </InlineToolRow>
) )
} }

View file

@ -119,7 +119,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
data.on("session.next.prompt.admitted", message), data.on("session.next.prompt.admitted", message),
data.on("session.next.prompted", message), data.on("session.next.prompted", message),
data.on("session.next.context.updated", 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.shell.started", message),
data.on("session.next.agent.switched", message), data.on("session.next.agent.switched", message),
data.on("session.next.model.switched", message), data.on("session.next.model.switched", message),
@ -160,6 +162,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
export function reduceSessionRows(messages: SessionMessage[]) { export function reduceSessionRows(messages: SessionMessage[]) {
return messages.reduce<SessionRow[]>((rows, message) => { return messages.reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") { if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
completePrevious(rows) completePrevious(rows)
rows.push({ type: "message", messageID: message.id }) rows.push({ type: "message", messageID: message.id })
return rows return rows

View file

@ -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 { function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return { return {
type: "assistant", type: "assistant",