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 = [...@@ -79,8 +79,11 @@ const mcpRules = [
79 { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" },79 { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" },
80];80];
81const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules];81const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules];
82/** The thread's title, which the recorded session already carries. */
83const TITLE = "Prime check";
82const sessionInfo = (overrides: Record<string, unknown> = {}) => ({84const sessionInfo = (overrides: Record<string, unknown> = {}) => ({
83 id: SESSION,85 id: SESSION,
86 title: TITLE,
84 permissions: t3Rules,87 permissions: t3Rules,
85 projectID: "global",88 projectID: "global",
86 model: { id: "big-pickle", providerID: "opencode", variant: "default" },89 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
...@@ -226,7 +229,7 @@ const turnInput = (...@@ -226,7 +229,7 @@ const turnInput = (
226 modelSelection: ModelSelection = bigPickle,229 modelSelection: ModelSelection = bigPickle,
227 runtimeMode: "full-access" | "approval-required" = "full-access",230 runtimeMode: "full-access" | "approval-required" = "full-access",
228) => ({231) => ({
229 appThread: {} as OrchestrationV2AppThread,232 appThread: { title: TITLE } as OrchestrationV2AppThread,
230 threadId,233 threadId,
231 runId: RunId.make("run:opencode2-adapter"),234 runId: RunId.make("run:opencode2-adapter"),
232 runOrdinal: 1,235 runOrdinal: 1,
...@@ -433,6 +436,66 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {...@@ -433,6 +436,66 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
433 }).pipe(Effect.scoped),436 }).pipe(Effect.scoped),
434 );437 );
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
436 it.effect("ends a turn on the provider thread it started on", () =>499 it.effect("ends a turn on the provider thread it started on", () =>
437 Effect.gen(function* () {500 Effect.gen(function* () {
438 const { runtime, thread } = yield* resumed([501 const { runtime, thread } = yield* resumed([
...@@ -631,6 +694,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {...@@ -631,6 +694,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
631 ...turnInput(thread),694 ...turnInput(thread),
632 appThread: {695 appThread: {
633 id: threadId,696 id: threadId,
697 title: TITLE,
634 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },698 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
635 } as OrchestrationV2AppThread,699 } as OrchestrationV2AppThread,
636 });700 });
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts+18-12
...@@ -151,9 +151,10 @@ const planRules = (name: string) => [...@@ -151,9 +151,10 @@ const planRules = (name: string) => [
151 ...mcpRules(name),151 ...mcpRules(name),
152];152];
153153
154const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown>) => ({154const sessionInfo = (directory: string, permissions: ReadonlyArray<unknown>, title: string) => ({
155 data: {155 data: {
156 id: SESSION,156 id: SESSION,
157 title,
157 projectID: "global",158 projectID: "global",
158 model: { id: "big-pickle", providerID: "opencode", variant: "default" },159 model: { id: "big-pickle", providerID: "opencode", variant: "default" },
159 cost: 0,160 cost: 0,
...@@ -235,8 +236,9 @@ const createdSession = (...@@ -235,8 +236,9 @@ const createdSession = (
235 ],236 ],
236 }),237 }),
237 ...(narrows ? [out("agent.list", "<any>"), reply("agent.list", agentList(directory))] : []),238 ...(narrows ? [out("agent.list", "<any>"), reply("agent.list", agentList(directory))] : []),
238 out("session.create", { location: { directory }, model: "<any>", permissions }),239 // The thread's title, which is its name here.
239 reply("session.create", sessionInfo(directory, permissions)),240 out("session.create", { location: { directory }, model: "<any>", permissions, title: name }),
241 reply("session.create", sessionInfo(directory, permissions, name)),
240];242];
241243
242/** A recording with its scrubbed `<work>` directory replaced by `directory`. */244/** A recording with its scrubbed `<work>` directory replaced by `directory`. */
...@@ -348,7 +350,7 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -348,7 +350,7 @@ describe("OpenCode 2 through the orchestrator", () => {
348 ...answeredPrompt("FIRST"),350 ...answeredPrompt("FIRST"),
349 // The next turn resumes the session at its new selection.351 // The next turn resumes the session at its new selection.
350 out("session.get", { sessionID: SESSION }),352 out("session.get", { sessionID: SESSION }),
351 reply("session.get", sessionInfo(cwd, t3Rules(name))),353 reply("session.get", sessionInfo(cwd, t3Rules(name), name)),
352 out("session.switchModel", {354 out("session.switchModel", {
353 sessionID: SESSION,355 sessionID: SESSION,
354 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },356 model: { providerID: "opencode", id: "mimo-v2.6-flash-free" },
...@@ -401,7 +403,7 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -401,7 +403,7 @@ describe("OpenCode 2 through the orchestrator", () => {
401 ...answeredPrompt("FIRST"),403 ...answeredPrompt("FIRST"),
402 ...directoryModels(after),404 ...directoryModels(after),
403 out("session.get", { sessionID: SESSION }),405 out("session.get", { sessionID: SESSION }),
404 reply("session.get", sessionInfo(before, t3Rules(name))),406 reply("session.get", sessionInfo(before, t3Rules(name), name)),
405 // The worktree change detached the thread, so its session is loaded afresh.407 // The worktree change detached the thread, so its session is loaded afresh.
406 ...noOpenRequests,408 ...noOpenRequests,
407 out("session.move", { sessionID: SESSION, directory: after }),409 out("session.move", { sessionID: SESSION, directory: after }),
...@@ -450,10 +452,14 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -450,10 +452,14 @@ describe("OpenCode 2 through the orchestrator", () => {
450 out("session.get", { sessionID: SESSION }),452 out("session.get", { sessionID: SESSION }),
451 reply(453 reply(
452 "session.get",454 "session.get",
453 sessionInfo(before, [455 sessionInfo(
454 { action: "*", resource: "*", effect: "allow" },456 before,
455 { action: "subagent", resource: "*", effect: "deny" },457 [
456 ]),458 { action: "*", resource: "*", effect: "allow" },
459 { action: "subagent", resource: "*", effect: "deny" },
460 ],
461 name,
462 ),
457 ),463 ),
458 ...noOpenRequests,464 ...noOpenRequests,
459 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),465 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
...@@ -531,7 +537,7 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -531,7 +537,7 @@ describe("OpenCode 2 through the orchestrator", () => {
531 ...answeredPrompt("FIRST"),537 ...answeredPrompt("FIRST"),
532 // A mode change detaches nothing: the same session is resumed with the new rules.538 // A mode change detaches nothing: the same session is resumed with the new rules.
533 out("session.get", { sessionID: SESSION }),539 out("session.get", { sessionID: SESSION }),
534 reply("session.get", sessionInfo(cwd, t3Rules(name))),540 reply("session.get", sessionInfo(cwd, t3Rules(name), name)),
535 out("agent.list", "<any>"),541 out("agent.list", "<any>"),
536 reply("agent.list", agentList(cwd)),542 reply("agent.list", agentList(cwd)),
537 out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }),543 out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }),
...@@ -539,7 +545,7 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -539,7 +545,7 @@ describe("OpenCode 2 through the orchestrator", () => {
539 ...answeredPrompt("SECOND"),545 ...answeredPrompt("SECOND"),
540 // Back to Full access: the narrowing rules go.546 // Back to Full access: the narrowing rules go.
541 out("session.get", { sessionID: SESSION }),547 out("session.get", { sessionID: SESSION }),
542 reply("session.get", sessionInfo(cwd, autoEditRules(name))),548 reply("session.get", sessionInfo(cwd, autoEditRules(name), name)),
543 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),549 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
544 reply("session.update", null),550 reply("session.update", null),
545 ...answeredPrompt("THIRD"),551 ...answeredPrompt("THIRD"),
...@@ -577,7 +583,7 @@ describe("OpenCode 2 through the orchestrator", () => {...@@ -577,7 +583,7 @@ describe("OpenCode 2 through the orchestrator", () => {
577 ...instructionsWritten,583 ...instructionsWritten,
578 ...answeredPrompt("PLANNED"),584 ...answeredPrompt("PLANNED"),
579 out("session.get", { sessionID: SESSION }),585 out("session.get", { sessionID: SESSION }),
580 reply("session.get", sessionInfo(cwd, planRules(name))),586 reply("session.get", sessionInfo(cwd, planRules(name), name)),
581 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),587 out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }),
582 reply("session.update", null),588 reply("session.update", null),
583 out("session.switchAgent", { sessionID: SESSION, agent: "build" }),589 out("session.switchAgent", { sessionID: SESSION, agent: "build" }),
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+2
...@@ -651,6 +651,7 @@ export const layer: Layer.Layer<...@@ -651,6 +651,7 @@ export const layer: Layer.Layer<
651 return yield* loadFromProvider(651 return yield* loadFromProvider(
652 session.ensureThread({652 session.ensureThread({
653 threadId: projection.thread.id,653 threadId: projection.thread.id,
654 title: projection.thread.title,
654 modelSelection: run.modelSelection,655 modelSelection: run.modelSelection,
655 runtimePolicy: resolvedRuntimePolicy,656 runtimePolicy: resolvedRuntimePolicy,
656 providerSessionId,657 providerSessionId,
...@@ -696,6 +697,7 @@ export const layer: Layer.Layer<...@@ -696,6 +697,7 @@ export const layer: Layer.Layer<
696 const replacement = yield* loadFromProvider(697 const replacement = yield* loadFromProvider(
697 session.ensureThread({698 session.ensureThread({
698 threadId: projection.thread.id,699 threadId: projection.thread.id,
700 title: projection.thread.title,
699 modelSelection: run.modelSelection,701 modelSelection: run.modelSelection,
700 runtimePolicy: resolvedRuntimePolicy,702 runtimePolicy: resolvedRuntimePolicy,
701 providerSessionId,703 providerSessionId,
apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson+2
...@@ -50,6 +50,8 @@...@@ -50,6 +50,8 @@
50{"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"}]}}}50{"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"}]}}}
51{"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}}51{"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}}
52{"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}}}}52{"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}}
53{"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": "<any>"}}}55{"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": "<any>"}}}
54{"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}}56{"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}}
55{"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": "<any>"}}}57{"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 @@...@@ -59,6 +59,8 @@
59{"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}}59{"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}}
60{"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}}}}60{"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}}}}
61{"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}}}}61{"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}}
62{"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}}64{"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}}
63{"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}}65{"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}}
64{"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}}}}66{"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 {...@@ -394,6 +394,8 @@ export interface ProviderAdapterV2OpenSessionInput {
394394
395export interface ProviderAdapterV2EnsureThreadInput {395export interface ProviderAdapterV2EnsureThreadInput {
396 readonly threadId: ThreadId;396 readonly threadId: ThreadId;
397 /** The app thread's title, for providers that name their native sessions. */
398 readonly title?: string;
397 readonly modelSelection: ModelSelection;399 readonly modelSelection: ModelSelection;
398 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;400 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
399 readonly providerSessionId?: ProviderSessionId;401 readonly providerSessionId?: ProviderSessionId;
packages/provider-opencode/src/server/v2/adapter.ts+30-1
...@@ -345,6 +345,8 @@ interface ThreadState {...@@ -345,6 +345,8 @@ interface ThreadState {
345 * rules stay in force, and a changed mode switches it before prompting.345 * rules stay in force, and a changed mode switches it before prompting.
346 */346 */
347 agent: string;347 agent: string;
348 /** The native session's title as T3 last read or wrote it. */
349 title: string | undefined;
348 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */350 /** The native session's rules as T3 last read or wrote them, and the policy they are for. */
349 rules: ReadonlyArray<Rule> | undefined;351 rules: ReadonlyArray<Rule> | undefined;
350 policy: RulesPolicy;352 policy: RulesPolicy;
...@@ -934,6 +936,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -934,6 +936,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
934 unsettled: false,936 unsettled: false,
935 directory,937 directory,
936 agent: "build",938 agent: "build",
939 title: undefined,
937 rules: undefined,940 rules: undefined,
938 policy: input.runtimePolicy,941 policy: input.runtimePolicy,
939 grants: [],942 grants: [],
...@@ -3012,12 +3015,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3012,12 +3015,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3012 state.policy = policy;3015 state.policy = policy;
3013 });3016 });
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
3015 const register = (3027 const register = (
3016 providerThread: OrchestrationV2ProviderThread,3028 providerThread: OrchestrationV2ProviderThread,
3017 native: {3029 native: {
3018 readonly id: string;3030 readonly id: string;
3019 readonly model?: ModelRef | undefined;3031 readonly model?: ModelRef | undefined;
3020 readonly agent?: string | undefined;3032 readonly agent?: string | undefined;
3033 readonly title?: string | undefined;
3021 readonly permissions?: ReadonlyArray<Rule> | undefined;3034 readonly permissions?: ReadonlyArray<Rule> | undefined;
3022 },3035 },
3023 directory: string,3036 directory: string,
...@@ -3029,12 +3042,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3029,12 +3042,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3029 existing.model = native.model;3042 existing.model = native.model;
3030 existing.directory = directory;3043 existing.directory = directory;
3031 existing.agent = native.agent ?? existing.agent;3044 existing.agent = native.agent ?? existing.agent;
3045 existing.title = native.title;
3032 existing.rules = native.permissions;3046 existing.rules = native.permissions;
3033 return existing;3047 return existing;
3034 }3048 }
3035 const state = newThreadState(native.id, providerThread, directory, undefined);3049 const state = newThreadState(native.id, providerThread, directory, undefined);
3036 state.model = native.model;3050 state.model = native.model;
3037 state.agent = native.agent ?? state.agent;3051 state.agent = native.agent ?? state.agent;
3052 state.title = native.title;
3038 state.rules = native.permissions;3053 state.rules = native.permissions;
3039 threads.set(native.id, state);3054 threads.set(native.id, state);
3040 return state;3055 return state;
...@@ -3615,10 +3630,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3615,10 +3630,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3615 policy,3630 policy,
3616 threadInput.threadId,3631 threadInput.threadId,
3617 );3632 );
3633 const title = threadInput.title?.trim() || undefined;
3634 // A session created with a title is never titled by OpenCode's own model.
3618 const created = yield* client.session.create({3635 const created = yield* client.session.create({
3619 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),3636 location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }),
3620 model,3637 model,
3621 permissions,3638 permissions,
3639 ...(title === undefined ? {} : { title }),
3622 });3640 });
3623 const createdAt = yield* DateTime.now;3641 const createdAt = yield* DateTime.now;
3624 const providerThread: OrchestrationV2ProviderThread = {3642 const providerThread: OrchestrationV2ProviderThread = {
...@@ -3642,7 +3660,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3642,7 +3660,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3642 };3660 };
3643 const state = register(3661 const state = register(
3644 providerThread,3662 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 },
3646 directory,3670 directory,
3647 );3671 );
3648 state.policy = policy;3672 state.policy = policy;
...@@ -3801,6 +3825,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr...@@ -3801,6 +3825,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
3801 // and its subagents still running hold the rules they started with.3825 // and its subagents still running hold the rules they started with.
3802 // Those run on whether or not this turn starts, so theirs are best effort.3826 // Those run on whether or not this turn starts, so theirs are best effort.
3803 yield* writeRules(state, turnInput.runtimePolicy);3827 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 );
3804 for (const call of runningCalls(state)) {3833 for (const call of runningCalls(state)) {
3805 if (call.child === undefined) continue;3834 if (call.child === undefined) continue;
3806 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(3835 yield* writeRules(call.child, turnInput.runtimePolicy).pipe(