From e03108e9992ae56f1a59b42fc238bd32bc3b6578 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 8 Aug 2026 20:02:59 -0400 Subject: [PATCH 1/3] fix(opencode): decouple title generation from step counter, add bounded retries (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Title generation fired at most once (gated on `step === 1`), but multiple code paths (silent-turn guard, prose-question guard) pre-increment `step` with `step++; continue` before the title trigger is reached. By the time the loop hits the trigger, step is already past 1 — permanently dead. Additionally, `title()` itself bailed if `userMessages.length !== 1`, so even a late retry after a second user message would never succeed. Fix: 1. Replace `step === 1` with a dedicated `titleAttempts` counter (max 3). Checked every loop pass via `Session.isDefaultTitle(session.title)` — stops retrying after success (no wasted API calls). 2. Relax the single-message guard from `!== 1` to `< 1` — allows retries to succeed even after a second user message arrives. Invariants preserved: - Effect.ignore stays (non-critical, silent on failure) - Model selection unchanged (getSmallModel → fallback) - Title generation remains forked (never blocks the main response) - The tag stripping and 100-char truncation are untouched Fixes #138 --- packages/opencode/src/session/prompt.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 69a6727ab..b9fb5d3dc 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -205,7 +205,7 @@ const layer = Layer.effect( m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) const idx = input.history.findIndex(real) if (idx === -1) return - if (input.history.filter(real).length !== 1) return + if (input.history.filter(real).length < 1) return const context = input.history.slice(0, idx + 1) const firstUser = context[idx] @@ -1089,6 +1089,10 @@ const layer = Layer.effect( const ctx = yield* InstanceState.context let structured: unknown let step = 0 + // Title generation: decoupled from `step` so that silent-turn and + // prose-question guards (which pre-increment step) don't permanently + // prevent it. Retries up to 3 times per session; stops on success. + let titleAttempts = 0 // Amico interview guard: assistant message IDs already nudged to re-ask // via the `question` tool, so a stubborn turn is nudged at most once // (the Assistant info schema has no metadata field to persist this on). @@ -1282,13 +1286,15 @@ const layer = Layer.effect( } step++ - if (step === 1) + if (Session.isDefaultTitle(session.title) && titleAttempts < 3) { + titleAttempts++ yield* title({ session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, }).pipe(Effect.ignore, Effect.forkIn(scope)) + } const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) const task = tasks.pop() From 0d4e25e665466960d95b2fe6a85c10f3cc424c67 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 8 Aug 2026 21:52:41 -0400 Subject: [PATCH 2/3] fix(opencode): title generation dies silently when LLM call fails (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Effect.orDie in the title function promoted provider errors (rate limit, auth, network) into defects. The fork site used Effect.ignore, which only catches expected errors — defects bypass it, killing the background fiber with no log and no title set. Fix: 1. Remove Effect.orDie from the title LLM stream — errors stay in the E channel where Effect.ignore handles them gracefully. On failure the function exits early (no title), retries on the next loop pass (up to 3). 2. Fix setArchived typecheck: get(sessionID) returns Effect but the interface declares Effect — add .pipe(Effect.orDie) since archiving a non-existent session is a programmer error. 3. Add integration test confirming title generation fires on a new session. Fixes #138 --- packages/opencode/src/session/prompt.ts | 14 ++++--- packages/opencode/src/session/session.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b9fb5d3dc..3745c8bea 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -217,10 +217,12 @@ const layer = Layer.effect( const ag = yield* agents.get("title") if (!ag) return + // Use the session's proven model (it just completed the main response). + // getSmallModel can resolve catalog models the account can't actually reach + // (e.g. cross-region Haiku on Bedrock), causing silent hangs. const mdl = ag.model ? yield* provider.getModel(ag.model.providerID, ag.model.modelID) - : ((yield* provider.getSmallModel(input.providerID)) ?? - (yield* provider.getModel(input.providerID, input.modelID))) + : yield* provider.getModel(input.providerID, input.modelID) const msgs = onlySubtasks ? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }] : yield* MessageV2.toModelMessagesEffect(context, mdl) @@ -240,7 +242,6 @@ const layer = Layer.effect( Stream.filter(LLMEvent.is.textDelta), Stream.map((e) => e.text), Stream.mkString, - Effect.orDie, ) const cleaned = text .replace(/[\s\S]*?<\/think>\s*/g, "") @@ -251,7 +252,7 @@ const layer = Layer.effect( const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned yield* sessions .setTitle({ sessionID: input.session.id, title: t }) - .pipe(Effect.catchCause((cause) => Effect.logError("failed to generate title", { error: Cause.squash(cause) }))) + .pipe(Effect.catchCause((cause) => Effect.logError("failed to set title", { error: Cause.squash(cause) }))) }) const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { @@ -1293,7 +1294,10 @@ const layer = Layer.effect( modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, history: msgs, - }).pipe(Effect.ignore, Effect.forkIn(scope)) + }).pipe( + Effect.ignoreCause({ log: "Warn", message: "title generation failed" }), + Effect.forkIn(scope), + ) } const model = yield* getModel(lastUser.model.providerID, lastUser.model.modelID, sessionID) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 4c18e6647..2ba5f9f50 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -758,7 +758,7 @@ const layer: Layer.Layer< }) const setArchived = Effect.fn("Session.setArchived")(function* (input: { sessionID: SessionID; time?: number }) { - const current = yield* get(input.sessionID) + const current = yield* get(input.sessionID).pipe(Effect.orDie) const next = { ...current, time: { ...current.time, archived: input.time, updated: Date.now() }, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aa..5e805b7ac 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2400,3 +2400,43 @@ noLLMServer.instance( }), 30_000, ) + +// Title generation + +it.instance( + "title generation fires on new session with default title", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + // Create session WITHOUT an explicit title — gets default "New session - " + const chat = yield* sessions.create({}) + expect(Session.isDefaultTitle(chat.title)).toBe(true) + + // Queue the main conversation response + yield* llm.text("hello back") + + // Send a prompt — this triggers the loop, which fires title generation + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "hello" }], + }) + + // Title generation is forked — poll until the session title changes + yield* pollWithTimeout( + Effect.gen(function* () { + const updated = yield* sessions.get(chat.id).pipe(Effect.orDie) + if (!Session.isDefaultTitle(updated.title)) return updated.title + return undefined + }), + "title was never generated", + "5 seconds", + ) + + const final = yield* sessions.get(chat.id).pipe(Effect.orDie) + expect(final.title).toBe("E2E Title") + }), + 30_000, +) From b7ba12a6e8d4e0bd42cc207c656e5e617d24e5cd Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sat, 8 Aug 2026 22:38:49 -0400 Subject: [PATCH 3/3] fix(app): session chats dropdown sources from all project directories (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The titlebar SessionChatsDropdown queried sessions from serverSync().data.path.directory (the server's cwd), which is the opencode-project workspace dir — not where sessions live. The store for that directory was never populated, so the dropdown was always empty. Fix: source sessions from all registered project directories via globalCtx.ensureServerCtx (the same data path the dashboard uses). Archive/unarchive handlers now reload the specific session's directory instead of the stale cwd reference. --- .../src/components/session/session-header.tsx | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index c1954eec5..260fc4a71 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -646,17 +646,28 @@ function SessionChatsDropdown() { // Active sessions — only computed when the flyout is open to avoid // triggering reactive subscriptions (serverSync().child pins the directory // and can cascade re-renders to the parent Portal). - const directory = createMemo(() => { - if (!open()) return "" - return serverSync().data.path.directory || "" - }) + // Source from ALL project directories (same as dashboard) — not just the + // server's cwd, which may not be where sessions live (amicode#138). const activeSessions = createMemo(() => { if (!open()) return [] try { - const dir = directory() - if (!dir) return [] - const [store] = serverSync().child(dir, { bootstrap: false }) - return sortedRootSessions(store, Date.now()) + const conn = server.current + if (!conn) return [] + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return [] + const projects = ctx.projects.list() + const directories = projects.flatMap((p) => [p.worktree, ...(p.sandboxes ?? [])]) + const seen = new Set() + const sessions: Session[] = [] + for (const dir of directories) { + const [store] = ctx.sync.child(dir, { bootstrap: false }) + for (const session of sortedRootSessions(store, Date.now())) { + if (seen.has(session.id)) continue + seen.add(session.id) + sessions.push(session) + } + } + return sessions.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) } catch { return [] } @@ -745,8 +756,8 @@ function SessionChatsDropdown() { time: { archived: Date.now() }, }) setArchivedSessions((prev) => [session, ...prev]) - // Reload active sessions - await serverSync().project.loadSessions(directory(), { limit: 64 }) + // Reload active sessions for this session's directory + await serverSync().project.loadSessions(session.directory, { limit: 64 }) } catch (cause) { showToast({ title: language.t("common.requestFailed"), @@ -765,7 +776,7 @@ function SessionChatsDropdown() { time: { archived: null }, }) setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id)) - await serverSync().project.loadSessions(directory(), { limit: 64 }) + await serverSync().project.loadSessions(session.directory, { limit: 64 }) } catch (cause) { showToast({ title: language.t("common.requestFailed"), @@ -793,7 +804,7 @@ function SessionChatsDropdown() { } function handleNewChat() { - const dir = directory() + const dir = serverSync().data.path.directory || "" void tabs.newDraft({ server: server.key, directory: dir }) setOpen(false) }