authorgravatar for 43469098+nkoynov@users.noreply.github.comNikita Koynov <43469098+nkoynov@users.noreply.github.com> 2026-10-10 14:20:36+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 06:20:36-05:00
loge1ad595e0f10cc16d66fe54b4ee195435dd3cc0d
tree55c1f0d12ba1002566eccd953a1587dbe2709c40
parent4606f9d6f213952124d93792854b0c4e48658e60
signature Signed by PGP key B5690EEEBB952194

fix(opencode): name OpenCode 2 sessions after their thread (#17414)


7 files changed, 121 insertions(+), 14 deletions(-)

apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+65-1
......@@ -79,8 +79,11 @@ const mcpRules = [
7979 { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" },
8080];
8181const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules];
82/** The thread's title, which the recorded session already carries. */
83const TITLE = "Prime check";
8284const sessionInfo = (overrides: Record<string, unknown> = {}) => ({
8385 id: SESSION,
86 title: TITLE,
8487 permissions: t3Rules,
8588 projectID: "global",
8689 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
......@@ -226,7 +229,7 @@ const turnInput = (
226229 modelSelection: ModelSelection = bigPickle,
227230 runtimeMode: "full-access" | "approval-required" = "full-access",
228231) => ({
229 appThread: {} as OrchestrationV2AppThread,
232 appThread: { title: TITLE } as OrchestrationV2AppThread,
230233 threadId,
231234 runId: RunId.make("run:opencode2-adapter"),
232235 runOrdinal: 1,
......@@ -433,6 +436,66 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
433436 }).pipe(Effect.scoped),
434437 );
435438
439 it.effect("creates the session with the thread's title, so OpenCode generates none", () =>
440 Effect.gen(function* () {
441 const runtime = yield* openCode2ReplayRuntimeWithInstructions([
442 ...opening,
443 out("session.create", {
444 location: { directory: WORK },
445 model: { providerID: "opencode", id: "big-pickle" },
446 permissions: t3Rules,
447 title: "Check whether 391 is prime",
448 }),
449 replyData("session.create", sessionInfo({ title: "Check whether 391 is prime" })),
450 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
451 promptAccepted,
452 event("session.execution.succeeded", { sessionID: SESSION }),
453 ]);
454 const thread = yield* runtime.ensureThread({
455 threadId,
456 title: " Check whether 391 is prime ",
457 modelSelection: bigPickle,
458 runtimePolicy: policy(),
459 });
460 const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
461 yield* runtime.startTurn({
462 ...turnInput(thread),
463 appThread: { title: "Check whether 391 is prime" } as OrchestrationV2AppThread,
464 });
465 assert.equal((yield* Fiber.join(terminal))?.status, "completed");
466 }).pipe(Effect.scoped),
467 );
468
469 it.effect("renames the session before the first turn after the thread was renamed", () =>
470 Effect.gen(function* () {
471 const { runtime, thread } = yield* resumed([
472 out("session.update", { sessionID: SESSION, title: "Prime factors of 391" }),
473 reply("session.update", null),
474 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
475 promptAccepted,
476 event("session.execution.succeeded", { sessionID: SESSION }),
477 // Renamed once: the next turn under the same title sends no update.
478 out("session.prompt", { sessionID: SESSION, text: "<any>" }),
479 promptAccepted,
480 event("session.execution.succeeded", { sessionID: SESSION }),
481 ]);
482 const renamed = {
483 ...turnInput(thread),
484 appThread: { title: "Prime factors of 391" } as OrchestrationV2AppThread,
485 };
486 const first = yield* terminalOf(runtime).pipe(Effect.forkScoped);
487 yield* runtime.startTurn(renamed);
488 assert.equal((yield* Fiber.join(first))?.status, "completed");
489 const second = yield* terminalOf(runtime).pipe(Effect.forkScoped);
490 yield* runtime.startTurn({
491 ...renamed,
492 runId: RunId.make("run:opencode2-adapter:2"),
493 attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"),
494 });
495 assert.equal((yield* Fiber.join(second))?.status, "completed");
496 }).pipe(Effect.scoped),
497 );
498
436499 it.effect("ends a turn on the provider thread it started on", () =>
437500 Effect.gen(function* () {
438501 const { runtime, thread } = yield* resumed([
......@@ -631,6 +694,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
631694 ...turnInput(thread),
632695 appThread: {
633696 id: threadId,
697 title: TITLE,
634698 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
635699 } as OrchestrationV2AppThread,
636700 });
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+18-12
......@@ -151,9 +151,10 @@ const planRules = (name: string) => [
151151 ...mcpRules(name),
152152];
153153
154const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown>) => ({
154const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown>, title: string) => ({
155155 data: {
156156 id: SESSION,
157 title,
157158 projectID: "global",
158159 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
159160 cost: 0,
......@@ -235,8 +236,9 @@ const createdSession = (
235236 ],
236237 }),
237238 ...(narrows ? [out("agent.list", "<any>"), reply("agent.list", agentList(directory))] : []),
238 out("session.create", { location: { directory }, model: "<any>", permissions }),
239 reply("session.create", sessionInfo(directory, permissions)),
239 // The thread's title, which is its name here.
240 out("session.create", { location: { directory }, model: "<any>", permissions, title: name }),
241 reply("session.create", sessionInfo(directory, permissions, name)),
240242];
241243
242244/** A recording with its scrubbed `<work>` directory replaced by `directory`. */
......@@ -348,7 +350,7 @@ describe("OpenCode 2 through the orchestrator", () => {
348350 ...answeredPrompt("FIRST"),
349351 // The next turn resumes the session at its new selection.
350352 out("session.get", { sessionID: SESSION }),
351 reply("session.get", sessionInfo(cwd, t3Rules(name))),
353 reply("session.get", sessionInfo(cwd, t3Rules(name), name)),
352354 out("session.switchModel", {
353355 sessionID: SESSION,
354356 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },
......@@ -401,7 +403,7 @@ describe("OpenCode 2 through the orchestrator", () => {
401403 ...answeredPrompt("FIRST"),
402404 ...directoryModels(after),
403405 out("session.get", { sessionID: SESSION }),
404 reply("session.get", sessionInfo(before, t3Rules(name))),
406 reply("session.get", sessionInfo(before, t3Rules(name), name)),
405407 // The worktree change detached the thread, so its session is loaded afresh.
406408 ...noOpenRequests,
407409 out("session.move", { sessionID: SESSION, directory: after }),
......@@ -450,10 +452,14 @@ describe("OpenCode 2 through the orchestrator", () => {
450452 out("session.get", { sessionID: SESSION }),
451453 reply(
452454 "session.get",
453 sessionInfo(before, [
454 { action: "*", resource: "*", effect: "allow" },
455 { action: "subagent", resource: "*", effect: "deny" },
456 ]),
455 sessionInfo(
456 before,
457 [
458 { action: "*", resource: "*", effect: "allow" },
459 { action: "subagent", resource: "*", effect: "deny" },
460 ],
461 name,
462 ),
457463 ),
458464 ...noOpenRequests,
459465 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
......@@ -531,7 +537,7 @@ describe("OpenCode 2 through the orchestrator", () => {
531537 ...answeredPrompt("FIRST"),
532538 // A mode change detaches nothing: the same session is resumed with the new rules.
533539 out("session.get", { sessionID: SESSION }),
534 reply("session.get", sessionInfo(cwd, t3Rules(name))),
540 reply("session.get", sessionInfo(cwd, t3Rules(name), name)),
535541 out("agent.list", "<any>"),
536542 reply("agent.list", agentList(cwd)),
537543 out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }),
......@@ -539,7 +545,7 @@ describe("OpenCode 2 through the orchestrator", () => {
539545 ...answeredPrompt("SECOND"),
540546 // Back to Full access: the narrowing rules go.
541547 out("session.get", { sessionID: SESSION }),
542 reply("session.get", sessionInfo(cwd, autoEditRules(name))),
548 reply("session.get", sessionInfo(cwd, autoEditRules(name), name)),
543549 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
544550 reply("session.update", null),
545551 ...answeredPrompt("THIRD"),
......@@ -577,7 +583,7 @@ describe("OpenCode 2 through the orchestrator", () => {
577583 ...instructionsWritten,
578584 ...answeredPrompt("PLANNED"),
579585 out("session.get", { sessionID: SESSION }),
580 reply("session.get", sessionInfo(cwd, planRules(name))),
586 reply("session.get", sessionInfo(cwd, planRules(name), name)),
581587 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
582588 reply("session.update", null),
583589 out("session.switchAgent", { sessionID: SESSION, agent: "build" }),
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+2
......@@ -651,6 +651,7 @@ export const layer: Layer.Layer<
651651 return yield* loadFromProvider(
652652 session.ensureThread({
653653 threadId: projection.thread.id,
654 title: projection.thread.title,
654655 modelSelection: run.modelSelection,
655656 runtimePolicy: resolvedRuntimePolicy,
656657 providerSessionId,
......@@ -696,6 +697,7 @@ export const layer: Layer.Layer<
696697 const replacement = yield* loadFromProvider(
697698 session.ensureThread({
698699 threadId: projection.thread.id,
700 title: projection.thread.title,
699701 modelSelection: run.modelSelection,
700702 runtimePolicy: resolvedRuntimePolicy,
701703 providerSessionId,
apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson+2
......@@ -50,6 +50,8 @@
5050{"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}}
5151{"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}}
5252{"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}}
53{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "title": "<any>"}}}
54{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}}
5355{"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": "<any>"}}}
5456{"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}}
5557{"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": "<any>"}}}
apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson+2
......@@ -59,6 +59,8 @@
5959{"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}}
6060{"type": "emit_inbound", "label": "session.agent.selected.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef80001lOHUbFIwlg7iap", "created": 1790657359744, "type": "session.agent.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build", "previous": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 16, "version": 1}}}}
6161{"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef830020JKd2UmFNPZ8vu", "created": 1790657359747, "type": "session.inbox.enqueued", "location": {"directory": "<work>"}, "data": {"inboxID": "msg_0eb7eef83001M1o8rpSkbKzlf5", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "<system-reminder>\nYou are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.\n</system-reminder>"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 17, "version": 1}}}}
62{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "title": "<any>"}}}
63{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}}
6264{"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}}
6365{"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}}
6466{"type": "emit_inbound", "label": "session.model.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef8d001Yy5fmSDr6nQ8OS", "created": 1790657359757, "type": "session.model.selected", "location": {"directory": "<work>"}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode"}, "previous": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 18, "version": 1}}}}
packages/provider-core/src/server/ProviderAdapter.ts+2
......@@ -394,6 +394,8 @@ export interface ProviderAdapterV2OpenSessionInput {
394394
395395export interface ProviderAdapterV2EnsureThreadInput {
396396 readonly threadId: ThreadId;
397 /** The app thread's title, for providers that name their native sessions. */
398 readonly title?: string;
397399 readonly modelSelection: ModelSelection;
398400 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
399401 readonly providerSessionId?: ProviderSessionId;
packages/provider-opencode/src/server/v2/adapter.ts+30-1
......@@ -345,6 +345,8 @@ interface ThreadState {
345345 * rules stay in force, and a changed mode switches it before prompting.
346346 */
347347 agent: string;
348 /** The native session's title as T3 last read or wrote it. */
349 title: string | undefined;
348350 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */
349351 rules: ReadonlyArray<Rule> | undefined;
350352 policy: RulesPolicy;
......@@ -934,6 +936,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
934936 unsettled: false,
935937 directory,
936938 agent: "build",
939 title: undefined,
937940 rules: undefined,
938941 policy: input.runtimePolicy,
939942 grants: [],
......@@ -3012,12 +3015,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
30123015 state.policy = policy;
30133016 });
30143017
3018 /** Names the session after its thread when they differ. */
3019 const writeTitle = Effect.fnUntraced(function* (state: ThreadState, title: string) {
3020 const next = title.trim();
3021 // An empty title asks OpenCode to generate one.
3022 if (next === "" || next === state.title) return;
3023 yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), title: next });
3024 state.title = next;
3025 });
3026
30153027 const register = (
30163028 providerThread: OrchestrationV2ProviderThread,
30173029 native: {
30183030 readonly id: string;
30193031 readonly model?: ModelRef | undefined;
30203032 readonly agent?: string | undefined;
3033 readonly title?: string | undefined;
30213034 readonly permissions?: ReadonlyArray<Rule> | undefined;
30223035 },
30233036 directory: string,
......@@ -3029,12 +3042,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
30293042 existing.model = native.model;
30303043 existing.directory = directory;
30313044 existing.agent = native.agent ?? existing.agent;
3045 existing.title = native.title;
30323046 existing.rules = native.permissions;
30333047 return existing;
30343048 }
30353049 const state = newThreadState(native.id, providerThread, directory, undefined);
30363050 state.model = native.model;
30373051 state.agent = native.agent ?? state.agent;
3052 state.title = native.title;
30383053 state.rules = native.permissions;
30393054 threads.set(native.id, state);
30403055 return state;
......@@ -3615,10 +3630,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
36153630 policy,
36163631 threadInput.threadId,
36173632 );
3633 const title = threadInput.title?.trim() || undefined;
3634 // A session created with a title is never titled by OpenCode's own model.
36183635 const created = yield* client.session.create({
36193636 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),
36203637 model,
36213638 permissions,
3639 ...(title === undefined ? {} : { title }),
36223640 });
36233641 const createdAt = yield* DateTime.now;
36243642 const providerThread: OrchestrationV2ProviderThread = {
......@@ -3642,7 +3660,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
36423660 };
36433661 const state = register(
36443662 providerThread,
3645 { id: created.id, model: created.model, agent: created.agent, permissions },
3663 {
3664 id: created.id,
3665 model: created.model,
3666 agent: created.agent,
3667 title: title ?? created.title,
3668 permissions,
3669 },
36463670 directory,
36473671 );
36483672 state.policy = policy;
......@@ -3801,6 +3825,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
38013825 // and its subagents still running hold the rules they started with.
38023826 // Those run on whether or not this turn starts, so theirs are best effort.
38033827 yield* writeRules(state, turnInput.runtimePolicy);
3828 // A thread renamed since the last turn, or titled after its first prompt.
3829 yield* writeTitle(state, turnInput.appThread.title).pipe(
3830 Effect.timeout(REQUEST_REPLY_TIMEOUT),
3831 Effect.ignore({ log: true }),
3832 );
38043833 for (const call of runningCalls(state)) {
38053834 if (call.child === undefined) continue;
38063835 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(