Skip to content

Commit 3053efc

Browse files
See USee U
authored andcommitted
feat(core): implement v2 session wait and skill activation
Wait: port upstream 5e90a68 — SessionRunCoordinator.awaitIdle joins in-process drains until idle; SessionV2.wait narrows to NotFoundError. Skill: port upstream 23adaaa — durable SessionEvent.Skill.Activated, SessionMessage.Skill projection through message-updater/projector/ to-llm-message/compaction, SessionV2.skill with SkillNotFoundError and fork-resume, POST /api/session/:sessionID/skill endpoint, SDK v2 regen. TUI UX, report skill, daemon, and plugin parts deferred; deviations registered in MERGE.md.
1 parent 4167479 commit 3053efc

24 files changed

Lines changed: 480 additions & 32 deletions

MERGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ fork 与上游改了同一处(常见于 TUI 视觉/UX、core 加固逻辑)
165165
| `packages/schema/src/session-event.ts` + `packages/core/src/session/runner/llm.ts` (v2 drain 失败 live 事件 `session.next.failed`) || 保留:新增 live-only `SessionEvent.Failed`(不入 durable manifest/不投影),`SessionRunner.run` 失败(非纯中断 cause)时发布,覆盖 step 开始前的失败(如 `ModelUnavailableError`)——此前 drain 失败仅 `execution/local.ts` logError,客户端不可见;step 内失败仍由 durable `Step.Failed` 结算(会双发,与 v1 `session.error` + assistant error 双通道一致);测试 `packages/core/test/session-runner.test.ts`(publishes a live failed event when a drain fails);连带更新 manifest 计数测试(schema 侧原本在 HEAD 即过期失败,修正为真实计数 89/59/35)+ SDK v2 重新生成(上游 runner 若补 durable 状态事件则取上游版本并移除本事件) |
166166
| `packages/tui/src/context/sdk.tsx` + `packages/sdk/js/src/v2/gen` (移除残留 `/sync/*` SDK 面) || 保留:fork 早前裁剪了 server `/sync/*` 路由,但 SDK gen 与 TUI `sdk.sync.start()`(实验 flag 下静默 404)未同步;本次 SDK 重新生成后一并删除 TUI 调用点(上游 sync 面回归时随上游恢复) |
167167
| `packages/core/src/{session.ts,session/compaction.ts,session/history.ts,session/runner/*}` (v2 手动 compact 实现) | 中 | 保留:实现 `SessionV2.compact`(原 stub 恒 `OperationUnavailableError`),spec `specs/v2/session.md:105` 已列为 sanctioned improvement。`SessionCompaction.make` 抽出共享 `summarize` 核心(`reason: "auto"\|"manual"`),新增 `compactManually`(输出预算取 `model.route.defaults.limits?.output`,可选 `instructions` 折入 `buildPrompt`);`SessionHistory.load` 改由新导出 `entries()`(读 epoch `baseline_seq` 只读,不触发 SystemContext 初始化)派生;`SessionRunner` 接口新增 `compact`,`SessionV2.compact` 经 `locations.get(session.location)` 路由,失败/无可压缩内容统一映射 `OperationUnavailableError`(HTTP 503 文案不变)。已知限制:手动 compact 不经 `SessionRunCoordinator` 串行化,与进行中 drain 并发时下一 turn 才生效;测试 `packages/core/test/session-runner.test.ts`(manually compacts on demand / returns false when nothing to compact)(上游实现 v2 手动 compact 后取上游版本) |
168+
| `packages/core/src/{session.ts,session/run-coordinator.ts,session/execution.ts,session/execution/local.ts}` (v2 `SessionV2.wait` 实现) || 保留:实现 `SessionV2.wait`(原 stub 恒 `OperationUnavailableError`)。逐字对齐上游 in-flight 提交 `5e90a68d6a`(wip(core): v2 subagent foundations):`SessionRunCoordinator.awaitIdle`(await entry `done` + `Effect.exit` 吞掉失败/中断、循环复查 successor 直到 idle,空闲即 no-op、不发起 drain),`SessionExecution.awaitIdle` 透传,`SessionV2.wait` 委托并收窄为 `NotFoundError`,server handler 移除死的 503 映射,literals 移除 `"wait"`(对齐 `bd8d858bf7` 前态)。语义限制:仅覆盖本进程持有的执行;测试为 fork 补充(上游 wip 无测试):`packages/core/test/session-run-coordinator.test.ts`(awaitIdle 4 例)+ `packages/opencode/test/server/httpapi-session.test.ts`(wait 改期望 204)(上游该分支合入后按上游版本对齐) |
169+
| `packages/{schema,core,protocol,server}/src/**` + `packages/sdk/js/src/v2/gen` (v2 `SessionV2.skill` 实现) | 中 | 保留:实现 `SessionV2.skill`(原 stub 恒 `OperationUnavailableError`)。逐字移植上游 in-flight 提交 `23adaaaeab`(feat(core): add native skill activation):durable `SessionEvent.Skill.Activated` + `SessionMessage.Skill` + projector/message-updater/to-llm-message/compaction 全链路,`SessionV2.skill` 经 `locations.get(session.location)` 解析 `SkillV2.list()`、未命中报 `Session.SkillNotFoundError`、`resume !== false` 时 fork `execution.resume`,协议新增 `POST /api/session/:sessionID/skill`(404 `SkillNotFoundError`)+ server handler + SDK v2 重新生成。未移植部分:TUI skill UX(依赖上游更新的 useLocation 管线)、report skill、cli daemon、plugin skill.ts、packages/client gen。测试为 fork 补充:`packages/core/test/session-create.test.ts`(activates a known skill as a durable skill message)+ httpapi-exercise/httpapi-session 覆盖;manifest 计数随 durable 事件 +1(60/90/90/36)(上游该分支合入后按上游版本对齐) |
168170

169171
### TUI 偏离(四批 23 轮审计打磨,全域)
170172

packages/core/src/session.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export * as SessionV2 from "./session"
22
export * from "./session/schema"
33

4-
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
4+
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
55
import { ListAnchor } from "@opencode-ai/schema/session"
66
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
77
import { ProjectV2 } from "./project"
@@ -37,6 +37,7 @@ import { SessionRevert } from "./session/revert"
3737
import { Revert } from "@opencode-ai/schema/revert"
3838
import { FSUtil } from "./fs-util"
3939
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
40+
import { SkillV2 } from "./skill"
4041

4142
export const RevertState = Revert.State
4243
export type RevertState = Revert.State
@@ -95,7 +96,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
9596
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
9697
"Session.OperationUnavailableError",
9798
{
98-
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
99+
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact"]),
99100
},
100101
) {}
101102

@@ -105,10 +106,13 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
105106
sessionID: SessionSchema.ID,
106107
messageID: SessionMessage.ID,
107108
}) {}
109+
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
110+
skill: Schema.String,
111+
}) {}
108112
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
109113
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
110114

111-
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
115+
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError | SkillNotFoundError
112116

113117
export interface Interface {
114118
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
@@ -158,13 +162,13 @@ export interface Interface {
158162
resume?: boolean
159163
}) => Effect.Effect<void, OperationUnavailableError>
160164
readonly skill: (input: {
161-
id?: EventV2.ID
165+
id?: SessionMessage.ID
162166
sessionID: SessionSchema.ID
163167
skill: string
164168
resume?: boolean
165-
}) => Effect.Effect<void, OperationUnavailableError>
169+
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
166170
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
167-
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
171+
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
168172
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
169173
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
170174
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -191,6 +195,7 @@ const layer = Layer.effect(
191195
const execution = yield* SessionExecution.Service
192196
const store = yield* SessionStore.Service
193197
const locations = yield* LocationServiceMap.Service
198+
const scope = yield* Scope.Scope
194199
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
195200
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
196201
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -387,8 +392,22 @@ const layer = Layer.effect(
387392
shell: Effect.fn("V2Session.shell")(function* () {
388393
return yield* new OperationUnavailableError({ operation: "shell" })
389394
}),
390-
skill: Effect.fn("V2Session.skill")(function* () {
391-
return yield* new OperationUnavailableError({ operation: "skill" })
395+
skill: Effect.fn("V2Session.skill")(function* (input) {
396+
const session = yield* result.get(input.sessionID)
397+
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
398+
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
399+
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
400+
yield* events.publish(SessionEvent.Skill.Activated, {
401+
sessionID: input.sessionID,
402+
messageID: input.id ?? SessionMessage.ID.create(),
403+
timestamp: yield* DateTime.now,
404+
name: skill.name,
405+
text: skill.content,
406+
})
407+
if (input.resume !== false)
408+
yield* execution
409+
.resume(input.sessionID)
410+
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
392411
}),
393412
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
394413
yield* result.get(input.sessionID)
@@ -427,7 +446,7 @@ const layer = Layer.effect(
427446
}),
428447
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
429448
yield* result.get(sessionID)
430-
return yield* new OperationUnavailableError({ operation: "wait" })
449+
yield* execution.awaitIdle(sessionID)
431450
}),
432451
active: execution.active,
433452
resume: Effect.fn("V2Session.resume")(function* (sessionID) {

packages/core/src/session/compaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const serialize = (message: SessionMessage.Message) => {
123123
}
124124
if (message.type === "system") return `[System update]: ${message.text}`
125125
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
126+
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
126127
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
127128
return ""
128129
}

packages/core/src/session/execution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface Interface {
1515
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
1616
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
1717
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
18+
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
19+
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
1820
}
1921

2022
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
@@ -30,5 +32,6 @@ export const noopLayer = Layer.succeed(
3032
resume: () => Effect.void,
3133
wake: () => Effect.void,
3234
interrupt: () => Effect.void,
35+
awaitIdle: () => Effect.void,
3336
}),
3437
)

packages/core/src/session/execution/local.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const layer = Layer.effect(
3333
interrupt: coordinator.interrupt,
3434
resume: coordinator.run,
3535
wake: coordinator.wake,
36+
awaitIdle: coordinator.awaitIdle,
3637
})
3738
}),
3839
)

packages/core/src/session/message-updater.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
157157
}),
158158
)
159159
},
160+
"session.next.skill.activated": (event) => {
161+
return adapter.appendMessage(
162+
SessionMessage.Skill.make({
163+
id: event.data.messageID,
164+
type: "skill",
165+
name: event.data.name,
166+
text: event.data.text,
167+
time: { created: event.data.timestamp },
168+
}),
169+
)
170+
},
160171
"session.next.shell.started": (event) => {
161172
return adapter.appendMessage(
162173
SessionMessage.Shell.make({

packages/core/src/session/projector.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,15 @@ const layer = Layer.effectDiscard(
376376
)
377377
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
378378
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
379+
yield* events.project(SessionEvent.Skill.Activated, (event) =>
380+
insertMessage(db, event, {
381+
id: event.data.messageID,
382+
type: "skill",
383+
name: event.data.name,
384+
text: event.data.text,
385+
time: { created: event.data.timestamp },
386+
}),
387+
)
379388
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
380389
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
381390
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))

packages/core/src/session/run-coordinator.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface Coordinator<Key, E> {
1212
readonly wake: (key: Key) => Effect.Effect<void>
1313
/** Stops active execution and waits for its cleanup. */
1414
readonly interrupt: (key: Key) => Effect.Effect<void>
15+
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
16+
readonly awaitIdle: (key: Key) => Effect.Effect<void>
1517
}
1618

1719
type Entry<E> = {
@@ -100,5 +102,15 @@ export const make = <Key, E>(options: {
100102
return Fiber.interrupt(entry.owner)
101103
})
102104

103-
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt }
105+
// Each successful drain reuses its entry.done across coalesced wakes, so one await
106+
// already spans steered and queued continuation. Re-check after it settles to cover a
107+
// fresh wake (or a failure/stopping successor) that installs a new entry.
108+
const awaitIdle = (key: Key): Effect.Effect<void> =>
109+
Effect.suspend(() => {
110+
const entry = active.get(key)
111+
if (entry === undefined) return Effect.void
112+
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
113+
})
114+
115+
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
104116
})

packages/core/src/session/runner/to-llm-message.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
131131
]
132132
case "synthetic":
133133
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
134+
case "skill":
135+
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
134136
case "system":
135137
return [Message.system(message.text)]
136138
case "shell":

packages/core/test/session-create.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { SessionInput } from "@opencode-ai/core/session/input"
2323
import { SessionEvent } from "@opencode-ai/core/session/event"
2424
import { SessionTable } from "@opencode-ai/core/session/sql"
2525
import { SessionStore } from "@opencode-ai/core/session/store"
26+
import { SkillV2 } from "@opencode-ai/core/skill"
27+
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
2628
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
2729
import { testEffect } from "./lib/effect"
2830
import { tmpdir } from "./fixture/tmpdir"
@@ -37,7 +39,14 @@ const projects = Layer.succeed(
3739
)
3840
const it = testEffect(
3941
AppNodeBuilder.build(
40-
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
42+
LayerNode.group([
43+
Database.node,
44+
EventV2.node,
45+
LocationServiceMap.node,
46+
SessionProjector.node,
47+
SessionStore.node,
48+
SessionV2.node,
49+
]),
4150
[
4251
[ProjectV2.node, projects],
4352
[SessionExecution.node, SessionExecution.noopLayer],
@@ -318,7 +327,40 @@ describe("SessionV2.create", () => {
318327
)
319328

320329
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
321-
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
330+
}),
331+
)
332+
333+
it.effect("activates a known skill as a durable skill message", () =>
334+
Effect.gen(function* () {
335+
const session = yield* SessionV2.Service
336+
const tmp = yield* Effect.acquireRelease(
337+
Effect.promise(() => tmpdir()),
338+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
339+
)
340+
const created = yield* session.create({
341+
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
342+
})
343+
const skills = yield* SkillV2.Service.pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
344+
yield* skills.transform((editor) =>
345+
editor.source({
346+
type: "embedded",
347+
skill: SkillV2.Info.make({
348+
name: "review",
349+
location: AbsolutePath.make(path.join(tmp.path, "review/SKILL.md")),
350+
content: "Review the changes carefully.",
351+
}),
352+
}),
353+
)
354+
355+
yield* session.skill({ sessionID: created.id, skill: "review" })
356+
357+
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
358+
{ type: "skill", name: "review", text: "Review the changes carefully." },
359+
])
360+
expect(yield* session.skill({ sessionID: created.id, skill: "missing" }).pipe(Effect.flip)).toMatchObject({
361+
_tag: "Session.SkillNotFoundError",
362+
skill: "missing",
363+
})
322364
}),
323365
)
324366

0 commit comments

Comments
 (0)