authorgravatar for tris203@gmail.comTristan Knight <tris203@gmail.com> 2026-07-20 09:43:07+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:43:07+00:00
log3235658c080bc12fcd1ffaa275aced98d225f2f6
tree79c1727ebfda14a9d6fa7afaf4936c0b53f0233d
parent749baec353d5675f1acac81f22aa15da573e71c7
signature Signed by PGP key B5690EEEBB952194

fix: delegate OpenCode session titles to provider (#3720)


2 files changed, 95 insertions(+), 4 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+45-1
......@@ -54,6 +54,7 @@ const runtimeMock = {
5454 state: {
5555 startCalls: [] as string[],
5656 sessionCreateUrls: [] as string[],
57 sessionCreateInputs: [] as Array<Record<string, unknown>>,
5758 authHeaders: [] as Array<string | null>,
5859 abortCalls: [] as string[],
5960 closeCalls: [] as string[],
......@@ -67,6 +68,7 @@ const runtimeMock = {
6768 reset() {
6869 this.state.startCalls.length = 0;
6970 this.state.sessionCreateUrls.length = 0;
71 this.state.sessionCreateInputs.length = 0;
7072 this.state.authHeaders.length = 0;
7173 this.state.abortCalls.length = 0;
7274 this.state.closeCalls.length = 0;
......@@ -122,8 +124,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
122124 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
123125 ({
124126 session: {
125 create: async () => {
127 create: async (input: Record<string, unknown>) => {
126128 runtimeMock.state.sessionCreateUrls.push(baseUrl);
129 runtimeMock.state.sessionCreateInputs.push(input);
127130 runtimeMock.state.authHeaders.push(
128131 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
129132 );
......@@ -765,6 +768,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
765768 }),
766769 );
767770
771 it.effect("lets OpenCode own session title generation and emits title metadata updates", () =>
772 Effect.gen(function* () {
773 const adapter = yield* OpenCodeAdapter;
774 const threadId = asThreadId("thread-opencode-title-sync");
775 runtimeMock.state.subscribedEvents = [
776 {
777 type: "session.updated",
778 properties: {
779 info: {
780 id: "http://127.0.0.1:9999/session",
781 title: "Investigate OpenCode title sync",
782 },
783 },
784 },
785 ];
786
787 const eventsFiber = yield* adapter.streamEvents.pipe(
788 Stream.filter((event) => event.threadId === threadId),
789 Stream.take(3),
790 Stream.runCollect,
791 Effect.forkChild,
792 );
793
794 yield* adapter.startSession({
795 provider: ProviderDriverKind.make("opencode"),
796 threadId,
797 runtimeMode: "full-access",
798 });
799
800 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
801 NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1);
802 NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false);
803
804 const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated");
805 NodeAssert.ok(metadataUpdated);
806 if (metadataUpdated.type === "thread.metadata.updated") {
807 NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync");
808 }
809 }),
810 );
811
768812 it.effect("writes provider-native observability records using the session thread id", () =>
769813 Effect.gen(function* () {
770814 const nativeEvents: Array<{
apps/server/src/provider/Layers/OpenCodeAdapter.ts+50-3
......@@ -65,6 +65,35 @@ type OpenCodeSubscribedEvent =
6565 ? TEvent
6666 : never;
6767
68function trimText(value: string | undefined | null): string | undefined {
69 const trimmed = value?.trim();
70 return trimmed && trimmed.length > 0 ? trimmed : undefined;
71}
72
73function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined {
74 const properties = "properties" in event ? event.properties : undefined;
75 if (!properties || typeof properties !== "object") {
76 return undefined;
77 }
78
79 const sessionID = (properties as { readonly sessionID?: unknown }).sessionID;
80 const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined;
81 if (sessionIDFromProperties) {
82 return sessionIDFromProperties;
83 }
84
85 const info = (properties as { readonly info?: { readonly id?: unknown } }).info;
86 return info && typeof info.id === "string" ? info.id : undefined;
87}
88
89function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined {
90 if (event.type !== "session.updated") {
91 return undefined;
92 }
93
94 return trimText(event.properties.info.title);
95}
96
6897interface OpenCodeSessionContext {
6998 session: ProviderSession;
7099 readonly client: OpencodeClient;
......@@ -643,8 +672,7 @@ export function makeOpenCodeAdapter(
643672 context: OpenCodeSessionContext,
644673 event: OpenCodeSubscribedEvent,
645674 ) {
646 const payloadSessionId =
647 "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined;
675 const payloadSessionId = openCodeEventSessionId(event);
648676 if (payloadSessionId !== context.openCodeSessionId) {
649677 return;
650678 }
......@@ -663,6 +691,26 @@ export function makeOpenCodeAdapter(
663691 });
664692
665693 switch (event.type) {
694 case "session.updated": {
695 const title = openCodeEventSessionTitle(event);
696 if (title) {
697 yield* emit({
698 ...(yield* buildEventBase({
699 threadId: context.session.threadId,
700 raw: event,
701 })),
702 type: "thread.metadata.updated",
703 payload: {
704 name: title,
705 metadata: {
706 sessionID: context.openCodeSessionId,
707 },
708 },
709 });
710 }
711 break;
712 }
713
666714 case "message.updated": {
667715 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
668716 if (event.properties.info.role === "assistant") {
......@@ -1069,7 +1117,6 @@ export function makeOpenCodeAdapter(
10691117 }
10701118 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
10711119 client.session.create({
1072 title: `T3 Code ${input.threadId}`,
10731120 permission: buildOpenCodePermissionRules(input.runtimeMode),
10741121 }),
10751122 );