From e4f81969b86b0fa074b6df2e16b505a3186592af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 16:21:57 +0200 Subject: [PATCH 01/18] feat(opencode): remote create_session fields, rename adoption, title sync Extend create_session wire with optional agent/model/orgId (strict v1, old-CLI degrade via client retry); claim org via session metadata (metadata > KILO_ORG_ID > auth); adopt system session.renamed via setTitle with consume-on-failure adoption marks; POST generation-aware title changes through readiness (auto-titles marked by ensureTitle, same-title Updated consumes pending adoptions). --- .changeset/remote-cli-lifecycle.md | 5 + .../src/kilo-sessions/kilo-sessions.ts | 128 +++- .../src/kilo-sessions/remote-sender.ts | 122 +++- .../src/kilo-sessions/rename-adoptions.ts | 28 + packages/opencode/src/session/prompt.ts | 17 +- .../sessions/ensure-title-mark.test.ts | 466 ++++++++++++++ .../sessions/kilo-sessions-title.test.ts | 576 ++++++++++++++++++ .../kilocode/sessions/remote-sender.test.ts | 344 +++++++++++ .../sessions/rename-adoptions.test.ts | 30 + .../kilocode/tool-registry-indexing.test.ts | 1 + .../test/kilocode/tool/notify-user.test.ts | 11 + 11 files changed, 1697 insertions(+), 31 deletions(-) create mode 100644 .changeset/remote-cli-lifecycle.md create mode 100644 packages/opencode/src/kilo-sessions/rename-adoptions.ts create mode 100644 packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts create mode 100644 packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts create mode 100644 packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts diff --git a/.changeset/remote-cli-lifecycle.md b/.changeset/remote-cli-lifecycle.md new file mode 100644 index 000000000000..8960b9409bea --- /dev/null +++ b/.changeset/remote-cli-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Remote CLI session lifecycle: create_session accepts optional agent, model, and orgId (org claim rides session metadata); CLI adopts backend renames via system session.renamed and POSTs local title changes (generation-aware) to the ingest title route so auto-titles and explicit renames stay in sync. diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 75f2b541adb4..b3d5d03bd490 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -29,6 +29,7 @@ import { RemoteSender } from "@/kilo-sessions/remote-sender" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { AttachedState } from "@/kilo-sessions/attached-state" +import { consumeAutoTitle, consumeRenameAdoption } from "@/kilo-sessions/rename-adoptions" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" @@ -60,6 +61,11 @@ export namespace KiloSessions { sessionID: string, input: { id: string; message: string }, ) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, never> + readonly reportSessionTitle: ( + sessionID: string, + title: string, + opts: { generated: boolean }, + ) => Effect.Effect<{ ok: true } | { ok: false; reason: string }, never> } export class Service extends Context.Service()("@kilocode/KiloSessions") {} @@ -332,8 +338,21 @@ export namespace KiloSessions { handlers.set(def.type, fn) } + // Last-known title per session so we only POST on actual title changes. + // Seed on Created and from existing rows at bootstrap so the first real + // rename (rename-before-prompt, or first rename after process restart) + // is not treated as a seed-only sighting and dropped (Decision 8). + const knownTitles = new Map() + yield* sessions.list().pipe( + Effect.map((list) => { + for (const s of list) knownTitles.set(s.id, s.title) + }), + Effect.orElseSucceed(() => undefined), + ) watch(Session.Event.Created, (evt) => { - const sessionID = evt.properties.info.id + const info = evt.properties.info + const sessionID = info.id + if (typeof info.title === "string") knownTitles.set(sessionID, info.title) return create(sessionID).catch((error) => log.error("share init create failed", { sessionID, error })) }) watch(Session.Event.Updated, async (evt) => { @@ -341,9 +360,24 @@ export namespace KiloSessions { const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null))) if (!session) return await ingest.sync(sessionID, [ - { type: "kilo_meta", data: await meta(sessionID) }, + { type: "kilo_meta", data: await meta(sessionID, session) }, { type: "session", data: transport(session) }, ]) + const prev = knownTitles.get(sessionID) + knownTitles.set(sessionID, session.title) + // Same-title Updated (setTitle no-op / double session.renamed): still + // consume a matching rename adoption so the mark cannot stick and + // swallow a later real local rename (Decision 8 last-write-wins). + if (prev === session.title) { + consumeRenameAdoption(sessionID, session.title) + return + } + // Unseeded first sight (race before Created): record only, do not POST. + // After Created/list seed, any title change is a real change and POSTs. + if (prev === undefined) return + if (consumeRenameAdoption(sessionID, session.title)) return + const generated = consumeAutoTitle(sessionID, session.title) + await reportTitleChange(sessionID, session.title, generated) }) watch(MessageV2.Event.Updated, async (evt) => { await ingest.sync(evt.properties.info.sessionID, [{ type: "message", data: evt.properties.info }]) @@ -471,7 +505,15 @@ export namespace KiloSessions { ) }) - return Service.of({ init, sendAgentNotification }) + const reportSessionTitle = Effect.fn("KiloSessions.reportSessionTitle")(function* ( + sessionID: string, + title: string, + opts: { generated: boolean }, + ) { + return yield* Effect.promise(() => reportTitleChange(sessionID, title, opts.generated)) + }) + + return Service.of({ init, sendAgentNotification, reportSessionTitle }) }), ) @@ -487,6 +529,7 @@ export namespace KiloSessions { export const testLayer = Layer.succeed(Service, { init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_connected" } as const), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" } as const), }) export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node])) @@ -1031,6 +1074,51 @@ export namespace KiloSessions { } } + async function reportTitleChange( + sessionID: string, + title: string, + generated: boolean, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + if (ingestDisabled) { + return { ok: false, reason: "not_connected" } + } + const readiness = await withTimeout( + resolveReadiness(sessionID), + agentNotificationTimeoutMs(), + "session title readiness timed out", + ).catch(() => ({ ok: false, reason: "not_connected" }) as const) + if (!readiness.ok) { + log.warn("report session title skipped", { sessionID, reason: readiness.reason }) + return readiness + } + return postSessionTitle(sessionID, readiness.client, title, generated) + } + + async function postSessionTitle( + sessionID: string, + client: Client, + title: string, + generated: boolean, + ): Promise<{ ok: true } | { ok: false; reason: string }> { + try { + const response = await client.fetch(`${client.url}/api/session/${encodeURIComponent(sessionID)}/title`, { + method: "POST", + body: JSON.stringify({ title, generated }), + }) + if (response.ok) { + log.info("session title reported", { sessionID, generated }) + return { ok: true } + } + const reason = `http_${response.status}` + log.error("session title report failed", { sessionID, status: response.status }) + return { ok: false, reason } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + log.error("session title report failed", { sessionID, error: reason }) + return { ok: false, reason } + } + } + export async function remove(sessionId: string) { const client = await getClient() if (!client) return @@ -1160,10 +1248,10 @@ export namespace KiloSessions { return AppRuntime.runPromise(Vcs.Service.use((svc) => svc.branch())) } - async function meta(sessionId?: string) { + async function meta(sessionId?: string, info?: Session.Info | null) { const override = sessionId ? KiloSession.resolvePlatform(sessionId) : undefined const platform = override || process.env["KILO_PLATFORM"] || "cli" - const orgId = await getOrgId() + const orgId = await getOrgId(sessionId, info) const gitBranch = await branch().catch(() => undefined) const gitUrl = await getGitUrl().catch(() => undefined) @@ -1175,7 +1263,16 @@ export namespace KiloSessions { } } - async function getOrgId(): Promise { + /** Test seam: exercise meta()/getOrgId without a preloaded Session.Info (get-failure fallback). */ + export async function _metaForTests(sessionId?: string, info?: Session.Info | null) { + return meta(sessionId, info) + } + + async function getOrgId(sessionId?: string, info?: Session.Info | null): Promise { + // Per-session org from metadata (remote create_session) wins over process-global env/auth. + const fromMeta = await resolveSessionOrg(sessionId, info) + if (fromMeta) return fromMeta + const env = process.env["KILO_ORG_ID"] if (isUuid(env)) return env @@ -1186,6 +1283,25 @@ export namespace KiloSessions { }) } + async function resolveSessionOrg( + sessionId?: string, + info?: Session.Info | null, + ): Promise { + if (!sessionId) return undefined + const resolved = + info !== undefined + ? info + : await (async () => { + const { AppRuntime } = await import("@/effect/app-runtime") + return AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(SessionID.make(sessionId))), + ).catch(() => null) + })() + if (!resolved) return undefined + const raw = resolved.metadata?.orgId + return typeof raw === "string" && isUuid(raw) ? raw : undefined + } + function isUuid(value: string | undefined): value is Uuid { if (!value) return false return Uuid.safeParse(value).success diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 8af252e6dd01..86dc860f1be6 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -2,6 +2,7 @@ import { RemoteCommand } from "@/kilo-sessions/remote-command" import { RemoteExit } from "@/kilo-sessions/remote-exit" import { RemoteModelCatalog } from "@/kilo-sessions/remote-model-catalog" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" +import { consumeRenameAdoption, markRenameAdopted } from "@/kilo-sessions/rename-adoptions" import type { RemoteWS } from "@/kilo-sessions/remote-ws" import { GlobalBus } from "@/bus/global" import { RemoteAttachments } from "@/kilocode/remote-attachments" @@ -45,12 +46,35 @@ const SuggestionData = z.object({ index: z.number().int().nonnegative(), }) -// kilocode_change start - create_session: strict v1 request, no other fields accepted +// kilocode_change start - create_session: strict v1 request with optional inheritance fields +const CreateSessionModel = z.object({ + providerID: z.string().min(1), + modelID: z.string().min(1), + variant: z.string().min(1).optional(), +}) const CreateSessionRequest = z .object({ protocolVersion: z.literal(1), + agent: z.string().min(1).optional(), + model: CreateSessionModel.optional(), + orgId: z.string().uuid().optional(), }) .strict() + +type CreateSessionInput = { + agent?: string + model?: { + id: ModelV2.ID + providerID: ProviderV2.ID + variant?: string + } + metadata?: { orgId: string } +} + +const SessionRenamedData = z.object({ + sessionId: z.string().min(1), + title: z.string().min(1), +}) // kilocode_change end const decodeSessionID = Schema.decodeUnknownOption(SessionID) @@ -123,15 +147,15 @@ export namespace RemoteSender { readonly get: (sessionID: SessionID) => Promise readonly children: (sessionID: SessionID) => Promise // kilocode_change start - injectable create hook for create_session. - // create_session only ever calls `create({})` for a root session, so the - // test hook is typed as the loose `() => Promise` shape. - // Production falls back to Session.Service.create with `{}`. - readonly create?: (input?: Record) => Promise + // Production forwards {agent, model, metadata} to Session.Service.create. + readonly create?: (input?: CreateSessionInput) => Promise // kilocode_change - injectable remove hook used to roll back an orphan // root session when the spawn fails after creation. The default // delegates to Session.Service.remove and only swallows its own errors // so the original spawn failure is what reaches the caller. readonly remove?: (sessionID: SessionID) => Promise + // kilocode_change - injectable setTitle for system session.renamed handling + readonly setTitle?: (input: { sessionID: SessionID; title: string }) => Promise // kilocode_change end } // kilocode_change - K1 W1: in-process attach/detach/ownership/cancel @@ -263,11 +287,15 @@ export namespace RemoteSender { // as a wiring bug (a missing seam is never a runtime fallback). const sessionCreate = session.create ?? - (async (input?: Record) => { + (async (input?: CreateSessionInput) => { const { AppRuntime } = await import("@/effect/app-runtime") - return AppRuntime.runPromise( - Session.Service.use((svc) => svc.create(input as Parameters[0])), - ) + return AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))) + }) + const sessionSetTitle = + session.setTitle ?? + (async (input: { sessionID: SessionID; title: string }) => { + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(Session.Service.use((svc) => svc.setTitle(input))) }) const attachSession = options.attachSession ?? @@ -764,18 +792,12 @@ export namespace RemoteSender { return } if (msg.command === "create_session") { - // kilocode_change - K1 W1: in-process create_session. The wire - // shape is unchanged (`{protocolVersion: 1}`), but the handler now - // (a) accepts an absent `sessionId` (the instance-picker path is - // connectionId-targeted — no source session needed), (b) resolves - // the target directory to that existing session's directory when - // a `sessionId` is present (legacy mobile /new-inside-a-session - // path) or to `options.directory` (the instance's own launch - // directory) otherwise, and (c) attaches the new session in the - // same CLI process (concurrent sessions share the process with - // per-directory InstanceRef isolation) instead of spawning a child. - // Attach failures roll back the pre-created session via - // `sessionRemove`. + // kilocode_change - K1 W1: in-process create_session. Optional wire + // fields (agent/model/orgId) ride protocolVersion 1; orgId lands in + // session metadata so the first kilo_meta carries the claim. Handler + // (a) accepts an absent `sessionId` (instance-picker path), (b) + // resolves the target directory from that session or options.directory, + // (c) attaches in-process; attach failures roll back via sessionRemove. const parsed = CreateSessionRequest.safeParse(msg.data) if (!parsed.success) { options.conn.send({ @@ -794,6 +816,19 @@ export namespace RemoteSender { }) return } + const createInput: CreateSessionInput = { + ...(parsed.data.agent ? { agent: parsed.data.agent } : {}), + ...(parsed.data.model + ? { + model: { + id: ModelV2.ID.make(parsed.data.model.modelID), + providerID: ProviderV2.ID.make(parsed.data.model.providerID), + ...(parsed.data.model.variant ? { variant: parsed.data.model.variant } : {}), + }, + } + : {}), + ...(parsed.data.orgId ? { metadata: { orgId: parsed.data.orgId } } : {}), + } const run = options.provide ?? provide void (async () => { try { @@ -809,7 +844,7 @@ export namespace RemoteSender { const result = await run({ directory: targetDirectory, fn: async () => { - const created = await sessionCreate({}) + const created = await sessionCreate(createInput) // attachSession is the duplicate-safe seam: it mutates the // attached set exactly once and fires conn.heartbeat() only // when the set actually changes, so the relay learns about @@ -1068,6 +1103,49 @@ export namespace RemoteSender { return } if (msg.type === "system") { + if (msg.event === "session.renamed") { + const parsed = SessionRenamedData.safeParse(msg.data) + if (!parsed.success) { + options.log.warn("malformed session.renamed", { data: msg.data }) + return + } + const sid = decodeSessionID(parsed.data.sessionId) + if (Option.isNone(sid)) { + options.log.warn("malformed session.renamed", { data: msg.data }) + return + } + const run = options.provide ?? provide + void (async () => { + const title = parsed.data.title + try { + const info = await session.get(sid.value) + await run({ + directory: info.directory, + fn: async () => { + // Mark before setTitle: Session.Event.Updated publishes inside + // setTitle and the kilo-sessions consumer is deferred, so a + // post-write mark races the title broadcast. Clear on failure + // (same consume-on-failure pattern ensureTitle uses for auto-titles) + // so a later local write to this title is not skipped as an adoption. + markRenameAdopted(sid.value, title) + try { + await sessionSetTitle({ sessionID: sid.value, title }) + } catch (error) { + consumeRenameAdoption(sid.value, title) + throw error + } + }, + }) + } catch (error) { + // get() failure never marked; setTitle failure cleared above. + options.log.warn("session.renamed apply failed", { + sessionId: parsed.data.sessionId, + error: errorName(error), + }) + } + })() + return + } options.log.info("system event", { event: msg.event }) return } diff --git a/packages/opencode/src/kilo-sessions/rename-adoptions.ts b/packages/opencode/src/kilo-sessions/rename-adoptions.ts new file mode 100644 index 000000000000..9e8bfe636d6a --- /dev/null +++ b/packages/opencode/src/kilo-sessions/rename-adoptions.ts @@ -0,0 +1,28 @@ +// Leaf module shared by remote-sender (rename adoption) and kilo-sessions +// (title broadcast + auto-title marking). Kept free of imports from either so +// neither side needs a static import of the other. + +const renames = new Map() +const autos = new Map() + +export function markRenameAdopted(sessionId: string, title: string) { + renames.set(sessionId, title) +} + +/** Consume a pending rename adoption when the title matches. */ +export function consumeRenameAdoption(sessionId: string, title: string): boolean { + if (renames.get(sessionId) !== title) return false + renames.delete(sessionId) + return true +} + +export function markAutoTitle(sessionId: string, title: string) { + autos.set(sessionId, title) +} + +/** Consume a pending auto-title mark when the title matches. */ +export function consumeAutoTitle(sessionId: string, title: string): boolean { + if (autos.get(sessionId) !== title) return false + autos.delete(sessionId) + return true +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7bee94335603..c0dfd5293601 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -85,6 +85,7 @@ import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { RepositoryCache } from "@opencode-ai/core/repository-cache" // kilocode_change +import { markAutoTitle, consumeAutoTitle } from "@/kilo-sessions/rename-adoptions" // kilocode_change // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -316,9 +317,19 @@ export const layer = Layer.effect( .find((line) => line.length > 0) if (!cleaned) return 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) }))) + // kilocode_change start - re-check default title + mark auto-title before setTitle + // so a mid-generation rename is not clobbered, and kilo-sessions can label the + // title POST as generated:true (mark before write — Updated publishes inside setTitle). + const fresh = yield* sessions.get(input.session.id).pipe(Effect.orElseSucceed(() => null)) + if (!fresh || !Session.isDefaultTitle(fresh.title)) return + markAutoTitle(input.session.id, t) + yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe( + Effect.catchCause((cause) => { + consumeAutoTitle(input.session.id, t) + return Effect.logError("failed to generate title", { error: Cause.squash(cause) }) + }), + ) + // kilocode_change end }) const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { diff --git a/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts new file mode 100644 index 000000000000..11a891b7f1fd --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts @@ -0,0 +1,466 @@ +import { expect } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { Database } from "@opencode-ai/core/database/database" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { RepositoryCache } from "@opencode-ai/core/repository-cache" +import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { MemoryService } from "@kilocode/kilo-memory/effect/service" +import { Deferred, Effect, Fiber, Layer } from "effect" +import * as Stream from "effect/Stream" +import { FetchHttpClient } from "effect/unstable/http" +import path from "path" +import { Agent as AgentSvc } from "../../../src/agent/agent" +import { Auth } from "../../../src/auth" +import { BackgroundJob } from "../../../src/background/job" +import { Bus } from "../../../src/bus" +import { Command } from "../../../src/command" +import { Config } from "../../../src/config/config" +import { Env } from "../../../src/env" +import { EventV2Bridge } from "../../../src/event-v2-bridge" +import { Format } from "../../../src/format" +import { Git } from "../../../src/git" +import { Image } from "../../../src/image/image" +import { consumeAutoTitle } from "../../../src/kilo-sessions/rename-adoptions" +import { KiloSessions } from "../../../src/kilo-sessions/kilo-sessions" +import { LSP } from "../../../src/lsp/lsp" +import { MCP } from "../../../src/mcp" +import { Permission } from "../../../src/permission" +import { Plugin } from "../../../src/plugin" +import { Provider as ProviderSvc } from "../../../src/provider/provider" +import { Question } from "../../../src/question" +import { Instruction } from "../../../src/session/instruction" +import { LLM } from "../../../src/session/llm" +import { SessionCompaction } from "../../../src/session/compaction" +import { SessionProcessor } from "../../../src/session/processor" +import { SessionPrompt } from "../../../src/session/prompt" +import { SessionRevert } from "../../../src/session/revert" +import { SessionRunState } from "../../../src/session/run-state" +import { Session } from "../../../src/session/session" +import { SessionStatus } from "../../../src/session/status" +import { SessionSummary } from "../../../src/session/summary" +import { SystemPrompt } from "../../../src/session/system" +import { Todo } from "../../../src/session/todo" +import { Skill } from "../../../src/skill" +import { Snapshot } from "../../../src/snapshot" +import { ToolRegistry } from "../../../src/tool/registry" +import { Truncate } from "../../../src/tool/truncate" +import { RuntimeFlags } from "../../../src/effect/runtime-flags" +import { TestInstance } from "../../fixture/fixture" +import { pollWithTimeout, testEffect } from "../../lib/effect" +import { TestLLMServer } from "../../lib/llm-server" + +// Drives the real SessionPrompt.ensureTitle path (forked on loop step 1) for: +// - mid-generation non-default skip (re-check before mark/setTitle) +// - mark-before-setTitle + clear mark when setTitle fails + +const ref = { + providerID: ProviderV2.ID.make("test"), + modelID: ModelV2.ID.make("test-model"), +} + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) + +/** Shared mutable hooks for ensureTitle integration tests. */ +const hooks = { + stallTitle: undefined as Deferred.Deferred | undefined, + titleStreamEntered: false, + failSetTitle: false, + setTitleCalls: [] as { sessionID: string; title: string }[], +} + +const llmWrapped = Layer.effect( + LLM.Service, + Effect.gen(function* () { + const inner = yield* LLM.Service + return { + stream: (input: Parameters[0]) => { + if (input.agent.name === "title" && hooks.stallTitle) { + hooks.titleStreamEntered = true + const gate = hooks.stallTitle + return Stream.unwrap( + Effect.gen(function* () { + yield* Deferred.await(gate) + return inner.stream(input) + }), + ) + } + return inner.stream(input) + }, + } satisfies LLM.Interface + }), +).pipe(Layer.provide(LLM.defaultLayer)) + +const sessionWrapped = Layer.effect( + Session.Service, + Effect.gen(function* () { + const inner = yield* Session.Service + return { + ...inner, + setTitle: (input: { sessionID: Session.Info["id"]; title: string }) => + Effect.gen(function* () { + hooks.setTitleCalls.push(input) + if (hooks.failSetTitle) { + // Die so ensureTitle's Effect.catchCause clears the auto-title mark. + return yield* Effect.die(new Error("setTitle failed for test")) + } + return yield* inner.setTitle(input) + }), + } as Session.Interface + }), +).pipe(Layer.provide(Session.defaultLayer)) + +function makePrompt() { + const deps = Layer.mergeAll( + sessionWrapped, + Snapshot.defaultLayer, + llmWrapped, + Env.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + ProviderSvc.defaultLayer, + lsp, + mcp, + FSUtil.defaultLayer, + BackgroundJob.defaultLayer, + status, + Database.defaultLayer, + EventV2Bridge.defaultLayer, + Bus.layer, + MemoryService.layer, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(RepositoryCache.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provide(Auth.defaultLayer), + Layer.provide(KiloSessions.testLayer), + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(deps), + ) + const compact = SessionCompaction.layer.pipe( + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(proc), + Layer.provideMerge(deps), + ) + return SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provide(summary), + Layer.provideMerge(run), + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provideMerge(question), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), + Layer.provideMerge(deps), + Layer.provide(summary), + ) +} + +const it = testEffect(Layer.mergeAll(TestLLMServer.layer, makePrompt())) + +function providerCfg(url: string): Partial { + return { + // Pin title/small generation to the TestLLMServer provider. Without this, + // getSmallModel("test") falls through to kilo-auto/small and ensureTitle + // never hits the local fixture (no setTitle, no E2E Title). + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +const writeConfig = Effect.fn("test.writeConfig")(function* (dir: string, config: Partial) { + const fs = yield* FSUtil.Service + yield* fs.writeWithDirs( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://app.kilo.ai/config.json", ...config }), + ) +}) + +const useServerConfig = Effect.fn("test.useServerConfig")(function* () { + const { directory: dir } = yield* TestInstance + const llm = yield* TestLLMServer + yield* writeConfig(dir, providerCfg(llm.url)) + return { dir, llm } +}) + +function resetHooks() { + hooks.stallTitle = undefined + hooks.titleStreamEntered = false + hooks.failSetTitle = false + hooks.setTitleCalls = [] +} + +/** Match prompt.test.ts: turn a Deferred into a thenable for TestLLMServer.hold. */ +const deferredAsPromise = (deferred: Deferred.Deferred): PromiseLike => ({ + then: (onfulfilled, onrejected) => { + Effect.runFork( + Deferred.await(deferred).pipe( + Effect.match({ + onFailure: (error) => { + onrejected?.(error) + }, + onSuccess: (value) => { + onfulfilled?.(value) + }, + }), + ), + ) + return deferredAsPromise(deferred) as PromiseLike + }, +}) + +it.instance( + "ensureTitle skips write when title turns non-default mid-generation", + () => + Effect.gen(function* () { + resetHooks() + const { llm } = yield* useServerConfig() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + expect(Session.isDefaultTitle(chat.title)).toBe(true) + + const gate = yield* Deferred.make() + hooks.stallTitle = gate + + yield* llm.text("assistant reply") + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "hello for title" }], + }) + .pipe(Effect.forkChild) + + // Wait until ensureTitle has entered the stalled title stream. + yield* pollWithTimeout( + Effect.sync(() => (hooks.titleStreamEntered ? true : undefined)), + "ensureTitle never entered title stream", + "15 seconds", + ) + + // Mid-generation rename: title is no longer default → ensureTitle must skip setTitle. + // Use inner path without recording (failSetTitle is false); wrap still records. + yield* sessions.setTitle({ sessionID: chat.id, title: "User renamed mid-gen" }) + yield* Deferred.succeed(gate, undefined).pipe(Effect.ignore) + hooks.stallTitle = undefined + + yield* Fiber.join(fiber) + + // Drain forked ensureTitle after the main loop finishes. + yield* Effect.sleep(400) + + const final = yield* sessions.get(chat.id) + expect(final.title).toBe("User renamed mid-gen") + expect(hooks.setTitleCalls.filter((c) => c.title === "E2E Title")).toHaveLength(0) + expect(consumeAutoTitle(chat.id, "E2E Title")).toBe(false) + }), + 20_000, +) + +it.instance( + "ensureTitle clears auto-title mark when setTitle fails", + () => + Effect.gen(function* () { + resetHooks() + const { llm } = yield* useServerConfig() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + expect(Session.isDefaultTitle(chat.title)).toBe(true) + + // Keep the prompt scope open (hold main LLM) until ensureTitle's setTitle runs; + // the title fork is scoped to the prompt and is interrupted when it ends. + hooks.failSetTitle = true + const releaseMain = yield* Deferred.make() + yield* llm.hold("assistant reply", deferredAsPromise(releaseMain)) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "hello for title fail" }], + }) + .pipe(Effect.forkChild) + + yield* pollWithTimeout( + Effect.sync(() => (hooks.setTitleCalls.length > 0 ? true : undefined)), + `ensureTitle never called setTitle; calls=${JSON.stringify(hooks.setTitleCalls)}`, + "15 seconds", + ) + yield* Effect.sleep(100) + + yield* Deferred.succeed(releaseMain, undefined).pipe(Effect.ignore) + yield* Fiber.join(fiber) + + const final = yield* sessions.get(chat.id) + expect(Session.isDefaultTitle(final.title)).toBe(true) + expect(hooks.setTitleCalls.length).toBeGreaterThanOrEqual(1) + for (const call of hooks.setTitleCalls) { + expect(consumeAutoTitle(chat.id, call.title)).toBe(false) + } + }), + 30_000, +) + +it.instance( + "ensureTitle leaves a consumable auto-title mark after a successful write", + () => + Effect.gen(function* () { + resetHooks() + const { llm } = yield* useServerConfig() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + expect(Session.isDefaultTitle(chat.title)).toBe(true) + consumeAutoTitle(chat.id, "E2E Title") + + // Hold main open until title setTitle runs (title fork is prompt-scoped). + // Do not stall the title stream — let TestLLMServer auto-reply "E2E Title". + const releaseMain = yield* Deferred.make() + yield* llm.hold("assistant reply", deferredAsPromise(releaseMain)) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "hello for title success" }], + }) + .pipe(Effect.forkChild) + + // Poll session title (works even if setTitle wrapper is bypassed) and hooks. + yield* pollWithTimeout( + Effect.gen(function* () { + const s = yield* sessions.get(chat.id).pipe(Effect.orElseSucceed(() => null)) + if (s?.title === "E2E Title") return true + if (hooks.setTitleCalls.some((c) => c.title === "E2E Title")) return true + return undefined + }), + `ensureTitle never applied E2E Title; calls=${JSON.stringify(hooks.setTitleCalls)}`, + "20 seconds", + ) + + // Mark is process-global and testLayer has no Updated consumer — still consumable. + expect(consumeAutoTitle(chat.id, "E2E Title")).toBe(true) + expect(consumeAutoTitle(chat.id, "E2E Title")).toBe(false) + + const titled = yield* sessions.get(chat.id) + expect(titled.title).toBe("E2E Title") + + yield* Deferred.succeed(releaseMain, undefined).pipe(Effect.ignore) + yield* Fiber.join(fiber) + }), + 40_000, +) diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts new file mode 100644 index 000000000000..ed030b3117fa --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -0,0 +1,576 @@ +import { afterEach, beforeEach, expect, spyOn } from "bun:test" +import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Auth } from "../../../src/auth" +import { Bus } from "../../../src/bus" +import { GlobalBus } from "../../../src/bus/global" +import type { Config } from "../../../src/config/config" +import { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache" +import { + consumeAutoTitle, + consumeRenameAdoption, + markAutoTitle, + markRenameAdopted, +} from "../../../src/kilo-sessions/rename-adoptions" +import { Session } from "../../../src/session/session" +import { TestConfig } from "../../fixture/config" +import { testEffect } from "../../lib/effect" +import { TestInstance } from "../../fixture/fixture" + +const KiloSessions = (await import("../../../src/kilo-sessions/kilo-sessions")).KiloSessions + +// Session must be provideMerged so yield* Session.Service and the +// KiloSessions Updated handler share one store (otherwise get() misses creates). +const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Auth.defaultLayer)) + +function layer(overrides: Partial = {}) { + return KiloSessions.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(Session.defaultLayer), + Layer.provide(TestConfig.layer(overrides)), + ) +} + +function reset(...tokens: string[]) { + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + clearInFlightCache("kilo-sessions:org") + for (const token of tokens) clearInFlightCache(`kilo-sessions:token-valid:${token}`) +} + +const ORG_META = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" +const ORG_ENV = "11111111-2222-4333-8444-555555555555" +const ORG_AUTH = "99999999-8888-4777-8666-555555555555" + +beforeEach(() => { + delete process.env.KILO_ORG_ID +}) + +afterEach(() => { + delete process.env.KILO_ORG_ID +}) + +type Req = { method: string; path: string; body?: any } + +function titlePosts(requests: Req[]) { + return requests.filter((r) => r.method === "POST" && r.path.endsWith("/title")) +} + +function metaItems(requests: Req[]) { + const items: any[] = [] + for (const r of requests) { + if (r.method !== "POST") continue + const data = r.body?.data + if (!Array.isArray(data)) continue + for (const item of data) { + if (item?.type === "kilo_meta") items.push(item.data) + } + } + return items +} + +function mockFetch(requests: Req[]) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const req = new Request(input, init) + const body = req.method === "POST" ? await req.json().catch(() => undefined) : undefined + requests.push({ method: req.method, path: new URL(url).pathname, body }) + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + const id = "remote-" + requests.length + return Response.json({ id, ingestPath: `/api/session/${id}/ingest` }) + } + if (new URL(url).pathname.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (new URL(url).pathname.endsWith("/title")) return Response.json({ title: "ok", applied: true }) + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) as typeof globalThis.fetch +} + +function emitUpdated(directory: string, sessionID: string, title: string) { + GlobalBus.emit("event", { + directory, + payload: { + id: `evt-${Date.now()}-${Math.random()}`, + type: Session.Event.Updated.type, + properties: { sessionID, info: { title } }, + }, + }) +} + +// Ingest queue debounces ~1s; wait past that and drain. +const settle = Effect.gen(function* () { + yield* Effect.sleep(1200) + yield* Effect.promise(() => KiloSessions.drainIngestForShutdown()) +}) + +it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountId", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_ORG_ID = ORG_ENV + reset("test-token") + + return Effect.gen(function* () { + const auth = yield* Auth.Service + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + const instance = yield* TestInstance + yield* auth.set("kilo", { + type: "oauth", + access: "x", + refresh: "y", + expires: Date.now() + 60_000, + accountId: ORG_AUTH, + }) + yield* kilo.init() + yield* Effect.sleep(30) + + // 1) metadata wins over env + const withMeta = yield* sessions.create({ metadata: { orgId: ORG_META } }) + yield* Effect.promise(() => KiloSessions.bootstrap(withMeta.id)) + requests.length = 0 + emitUpdated(instance.directory, withMeta.id, withMeta.title) + yield* settle + expect(metaItems(requests).some((m) => m.orgId === ORG_META)).toBe(true) + + // 2) env wins when metadata absent + requests.length = 0 + const plain = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(plain.id)) + requests.length = 0 + emitUpdated(instance.directory, plain.id, plain.title) + yield* settle + expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) + + // 3) auth accountId when env cleared + delete process.env.KILO_ORG_ID + clearInFlightCache("kilo-sessions:org") + requests.length = 0 + const authOnly = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(authOnly.id)) + requests.length = 0 + emitUpdated(instance.directory, authOnly.id, authOnly.title) + yield* settle + expect(metaItems(requests).some((m) => m.orgId === ORG_AUTH)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove("kilo").pipe(Effect.orDie) + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + delete process.env.KILO_ORG_ID + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("meta falls through invalid metadata orgId to env", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_ORG_ID = ORG_ENV + reset("test-token") + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + const instance = yield* TestInstance + yield* kilo.init() + yield* Effect.sleep(30) + + const bad = yield* sessions.create({ metadata: { orgId: "not-a-uuid" } }) + yield* Effect.promise(() => KiloSessions.bootstrap(bad.id)) + requests.length = 0 + emitUpdated(instance.directory, bad.id, bad.title) + yield* settle + expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + delete process.env.KILO_ORG_ID + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("meta falls back to env when Session.Service.get fails", () => { + const originalKey = process.env.KILO_API_KEY + process.env.KILO_ORG_ID = ORG_ENV + clearInFlightCache("kilo-sessions:org") + + return Effect.gen(function* () { + // No preloaded info + unknown session id → resolveSessionOrg get fails → env. + const result = yield* Effect.promise(() => + KiloSessions._metaForTests("ses_missing_for_meta_get_fail"), + ) + expect(result.orgId).toBe(ORG_ENV) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + delete process.env.KILO_ORG_ID + clearInFlightCache("kilo-sessions:org") + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("title broadcast: auto-title posts generated true; custom posts generated false; adoption skips", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + reset("test-token") + + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + yield* Effect.sleep(30) + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + const defaultTitle = created.title + expect(Session.isDefaultTitle(defaultTitle)).toBe(true) + + // Created seeds knownTitles; same-title Updated is a no-op POST-wise. + emitUpdated(instance.directory, id, defaultTitle) + yield* Effect.sleep(50) + expect(titlePosts(requests)).toHaveLength(0) + + // Auto-title: mark then setTitle so Updated consumer sees generated:true. + const auto = "Auto generated title" + markAutoTitle(id, auto) + yield* sessions.setTitle({ sessionID: id, title: auto }) + yield* Effect.sleep(300) + { + const posts = titlePosts(requests) + expect(posts.length).toBeGreaterThanOrEqual(1) + expect(posts[posts.length - 1].body).toEqual({ title: auto, generated: true }) + expect(posts[posts.length - 1].path).toBe(`/api/session/${id}/title`) + } + expect(consumeAutoTitle(id, auto)).toBe(false) + + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Custom A" }) + yield* Effect.sleep(300) + { + const posts = titlePosts(requests) + expect(posts.length).toBeGreaterThanOrEqual(1) + expect(posts[posts.length - 1].body).toEqual({ title: "Custom A", generated: false }) + } + + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Custom B" }) + yield* Effect.sleep(300) + { + const posts = titlePosts(requests) + expect(posts.length).toBeGreaterThanOrEqual(1) + expect(posts[posts.length - 1].body).toEqual({ title: "Custom B", generated: false }) + } + + requests.length = 0 + markRenameAdopted(id, "From cloud") + yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) + yield* Effect.sleep(300) + expect(titlePosts(requests)).toHaveLength(0) + expect(consumeRenameAdoption(id, "From cloud")).toBe(false) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance( + "title broadcast: same-title Updated consumes rename adoption (double session.renamed)", + () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + reset("test-token") + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + yield* Effect.sleep(30) + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + // Cloud rename applied once: marks adoption + setTitle → Updated consumes mark, no POST. + markRenameAdopted(id, "From cloud") + yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) + yield* Effect.sleep(300) + expect(titlePosts(requests)).toHaveLength(0) + expect(consumeRenameAdoption(id, "From cloud")).toBe(false) + + // C2 re-emits session.renamed (or setTitle same title): mark again, same-title Updated + // must still consume so a later real local rename is not swallowed. + markRenameAdopted(id, "From cloud") + yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) + yield* Effect.sleep(300) + expect(titlePosts(requests)).toHaveLength(0) + expect(consumeRenameAdoption(id, "From cloud")).toBe(false) + + // Later local rename to the same title must POST (Decision 8 last-write-wins). + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Local rename away" }) + yield* Effect.sleep(300) + yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) + yield* Effect.sleep(300) + const posts = titlePosts(requests) + expect(posts.some((p) => p.body?.title === "From cloud" && p.body?.generated === false)).toBe(true) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) + }, + 15_000, +) + +it.instance("title broadcast: first rename after create (rename-before-prompt) POSTs", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + reset("test-token") + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + yield* Effect.sleep(30) + + // create publishes Created (seeds knownTitles) then we rename before any prompt/Updated seed. + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + yield* sessions.setTitle({ sessionID: id, title: "Renamed before prompt" }) + yield* Effect.sleep(300) + const posts = titlePosts(requests) + expect(posts.length).toBeGreaterThanOrEqual(1) + expect(posts[posts.length - 1].body).toEqual({ title: "Renamed before prompt", generated: false }) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("title broadcast: first rename after restart seeds from list and POSTs", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + const requests: Req[] = [] + const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + reset("test-token") + + return Effect.gen(function* () { + const sessions = yield* Session.Service + // Pre-existing session (process restart: no Created in this process). + const existing = yield* sessions.create({}) + const id = existing.id + const priorTitle = existing.title + + // Fresh KiloSessions init seeds knownTitles from sessions.list(). + const kilo = yield* KiloSessions.Service + yield* kilo.init() + yield* Effect.sleep(30) + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + yield* sessions.setTitle({ sessionID: id, title: "First rename after restart" }) + yield* Effect.sleep(300) + const posts = titlePosts(requests) + expect(posts.length).toBeGreaterThanOrEqual(1) + expect(posts[posts.length - 1].body).toEqual({ title: "First rename after restart", generated: false }) + // Sanity: we actually changed away from the seeded title. + expect(priorTitle).not.toBe("First rename after restart") + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("reportSessionTitle goes through readiness and tolerates POST failure", () => { + const originalKey = process.env.KILO_API_KEY + const originalIngest = process.env.KILO_SESSION_INGEST_URL + const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + const requests: Req[] = [] + let titleStatus = 500 + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: "remote-r", ingestPath: "/api/session/remote-r/ingest" }) + } + if (path.endsWith("/title")) { + requests.push({ method: "POST", path }) + return new Response("fail", { status: titleStatus }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const request = spyOn(globalThis, "fetch").mockImplementation(fetch) + + process.env.KILO_API_KEY = "test-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" + process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + reset("test-token") + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + const created = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(created.id)) + + const failed = yield* kilo.reportSessionTitle(created.id, "X", { generated: false }) + expect(failed).toEqual({ ok: false, reason: "http_500" }) + expect(requests.some((r) => r.path.endsWith("/title"))).toBe(true) + + titleStatus = 200 + const ok = yield* kilo.reportSessionTitle(created.id, "Y", { generated: true }) + expect(ok).toEqual({ ok: true }) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = originalIngest + if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS + else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout + reset("test-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("reportSessionTitle reports not_connected when unauthenticated", () => { + const originalKey = process.env.KILO_API_KEY + delete process.env.KILO_API_KEY + reset() + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const result = yield* kilo.reportSessionTitle("ses_x", "T", { generated: false }) + expect(result).toEqual({ ok: false, reason: "not_connected" }) + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (originalKey === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = originalKey + reset() + }), + ), + Effect.provide(layer()), + ) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index e9ada022c8ec..482f121728f7 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -3585,5 +3585,349 @@ describe("RemoteSender slash commands", () => { await Promise.resolve() expect(sent).toEqual([{ type: "response", id: "req_rollback", error: "failed to exit session" }]) }) + + test("create_session forwards agent, model (with variant), and orgId metadata", async () => { + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make("ses_new"), directory: "/workspace/project-a" } as any + }, + }, + attachSession: async () => {}, + }) + + const response = expectResponse(conn, sent, "req_inherit") + sender.handle({ + type: "command", + id: "req_inherit", + command: "create_session", + data: { + protocolVersion: 1, + agent: "build", + model: { providerID: "kilo", modelID: "kilo-auto/efficient", variant: "high" }, + orgId: org, + }, + }) + await response.promise + response.restore() + + expect(createCalls).toEqual([ + { + agent: "build", + model: { + id: ModelV2.ID.make("kilo-auto/efficient"), + providerID: ProviderV2.ID.make("kilo"), + variant: "high", + }, + metadata: { orgId: org }, + }, + ]) + expect(sent).toEqual([ + { type: "response", id: "req_inherit", result: { protocolVersion: 1, sessionID: "ses_new" } }, + ]) + }) + + test("create_session accepts each optional field alone", async () => { + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const org = "11111111-2222-4333-8444-555555555555" + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any, + children: async () => [], + create: async (input) => { + createCalls.push(input) + return { id: SessionID.make(`ses_${createCalls.length}`), directory: "/tmp" } as any + }, + }, + attachSession: async () => {}, + }) + + for (const [id, data] of [ + ["req_agent", { protocolVersion: 1, agent: "plan" }], + ["req_model", { protocolVersion: 1, model: { providerID: "kilo", modelID: "m1" } }], + ["req_org", { protocolVersion: 1, orgId: org }], + ] as const) { + const response = expectResponse(conn, sent, id) + sender.handle({ type: "command", id, command: "create_session", data }) + await response.promise + response.restore() + } + + expect(createCalls).toEqual([ + { agent: "plan" }, + { model: { id: ModelV2.ID.make("m1"), providerID: ProviderV2.ID.make("kilo") } }, + { metadata: { orgId: org } }, + ]) + }) + + test("create_session still rejects unknown fields under strict schema", async () => { + const { conn, sent } = fakeConn() + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any, + children: async () => [], + create: async () => ({ id: SessionID.make("ses_x"), directory: "/tmp" }) as any, + }, + attachSession: async () => {}, + }) + sender.handle({ + type: "command", + id: "req_strict", + command: "create_session", + data: { protocolVersion: 1, agent: "build", unknown: true }, + }) + expect(sent).toEqual([{ type: "response", id: "req_strict", error: "invalid create_session command" }]) + }) + + test("create_session production default forwards agent, model, metadata into Session.Service.create", async () => { + // Omits options.session.create so the production default path runs: + // AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))). + // Spy both seams: AppRuntime (unit suite has no full runtime) and + // Session.Service.use so we observe the exact input the default forwards. + const { conn, sent } = fakeConn() + const createCalls: unknown[] = [] + const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + const createdId = SessionID.make("ses_prod_default") + const created = { + id: createdId, + slug: "prod", + projectID: ProjectV2.ID.make("project_test"), + directory: "/tmp", + title: "New session", + version: "test", + time: { created: 0, updated: 0 }, + } satisfies Session.Info + + const { AppRuntime } = await import("../../../src/effect/app-runtime") + const runSpy = spyOn(AppRuntime, "runPromise").mockImplementation(((effect: Effect.Effect) => + Effect.runPromise(effect as Effect.Effect)) as typeof AppRuntime.runPromise) + const useSpy = spyOn(Session.Service, "use").mockImplementation(((fn: (svc: Session.Interface) => unknown) => + fn({ + create: (input?: Parameters[0]) => { + createCalls.push(input) + return Effect.succeed(created) + }, + } as Session.Interface)) as typeof Session.Service.use) + + try { + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any, + children: async () => [], + // no create → production default + }, + attachSession: async () => {}, + }) + + const response = expectResponse(conn, sent, "req_prod_default") + sender.handle({ + type: "command", + id: "req_prod_default", + command: "create_session", + data: { + protocolVersion: 1, + agent: "build", + model: { providerID: "kilo", modelID: "kilo-auto/efficient", variant: "high" }, + orgId: org, + }, + }) + await response.promise + response.restore() + + expect(createCalls).toEqual([ + { + agent: "build", + model: { + id: ModelV2.ID.make("kilo-auto/efficient"), + providerID: ProviderV2.ID.make("kilo"), + variant: "high", + }, + metadata: { orgId: org }, + }, + ]) + expect(sent).toEqual([ + { + type: "response", + id: "req_prod_default", + result: { protocolVersion: 1, sessionID: createdId }, + }, + ]) + } finally { + useSpy.mockRestore() + runSpy.mockRestore() + } + }) + + test("system session.renamed applies setTitle and marks adoption", async () => { + const { conn } = fakeConn() + const titles: { sessionID: string; title: string }[] = [] + const warnings: unknown[][] = [] + const sid = SessionID.make("ses_renamed") + const { markRenameAdopted: _mark, consumeRenameAdoption } = await import( + "../../../src/kilo-sessions/rename-adoptions" + ) + // Clear any leftover mark from prior tests by consuming a non-match then ensuring clean. + consumeRenameAdoption(sid, "__none__") + + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: { ...nolog, warn: (...args: unknown[]) => warnings.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => { + if (sessionID !== sid) throw new Error("unknown") + return { id: sessionID, directory: "/workspace" } as any + }, + children: async () => [], + setTitle: async (input) => { + titles.push(input) + }, + }, + }) + + sender.handle({ + type: "system", + event: "session.renamed", + data: { sessionId: sid, title: "From cloud" }, + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(titles).toEqual([{ sessionID: sid, title: "From cloud" }]) + expect(consumeRenameAdoption(sid, "From cloud")).toBe(true) + expect(consumeRenameAdoption(sid, "From cloud")).toBe(false) + }) + + test("system session.renamed tolerates malformed payload and unknown events", async () => { + const { conn } = fakeConn() + const titles: unknown[] = [] + const warnings: unknown[][] = [] + const infos: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: { + info: (...args: unknown[]) => infos.push(args), + error: () => {}, + warn: (...args: unknown[]) => warnings.push(args), + }, + subscribe: fakeBus().subscribe, + session: { + get: async () => { + throw new Error("should not get") + }, + children: async () => [], + setTitle: async (input) => { + titles.push(input) + }, + }, + }) + + sender.handle({ type: "system", event: "session.renamed", data: { title: "missing id" } }) + sender.handle({ type: "system", event: "session.renamed", data: null }) + sender.handle({ type: "system", event: "other.event", data: {} }) + await Promise.resolve() + + expect(titles).toEqual([]) + expect(warnings.some((w) => w[0] === "malformed session.renamed")).toBe(true) + expect(infos.some((i) => i[0] === "system event" && (i[1] as any)?.event === "other.event")).toBe(true) + }) + + test("system session.renamed skips when session.get fails", async () => { + const { conn } = fakeConn() + const titles: unknown[] = [] + const warnings: unknown[][] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: { ...nolog, warn: (...args: unknown[]) => warnings.push(args) }, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async () => { + throw new Error("not found") + }, + children: async () => [], + setTitle: async (input) => { + titles.push(input) + }, + }, + }) + sender.handle({ + type: "system", + event: "session.renamed", + data: { sessionId: "ses_missing", title: "Nope" }, + }) + await Promise.resolve() + await Promise.resolve() + expect(titles).toEqual([]) + expect(warnings.some((w) => w[0] === "session.renamed apply failed")).toBe(true) + }) + + test("system session.renamed clears adoption mark when setTitle fails", async () => { + const { conn } = fakeConn() + const sid = SessionID.make("ses_rename_fail") + const { consumeRenameAdoption } = await import("../../../src/kilo-sessions/rename-adoptions") + consumeRenameAdoption(sid, "__none__") + + const sender = RemoteSender.create({ + conn, + directory: "/tmp", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; fn: () => R }) => input.fn(), + session: { + get: async (sessionID) => { + if (sessionID !== sid) throw new Error("unknown") + return { id: sessionID, directory: "/workspace" } as any + }, + children: async () => [], + setTitle: async () => { + throw new Error("setTitle boom") + }, + }, + }) + + sender.handle({ + type: "system", + event: "session.renamed", + data: { sessionId: sid, title: "Cloud title" }, + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + // Mark must be cleared so a later local write to the same title is not skipped. + expect(consumeRenameAdoption(sid, "Cloud title")).toBe(false) + }) }) // kilocode_change end diff --git a/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts b/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts new file mode 100644 index 000000000000..ba2f6257f40b --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { + consumeAutoTitle, + consumeRenameAdoption, + markAutoTitle, + markRenameAdopted, +} from "../../../src/kilo-sessions/rename-adoptions" + +describe("rename-adoptions", () => { + test("rename adoption consumes only on exact title match", () => { + markRenameAdopted("ses_a", "Cloud title") + expect(consumeRenameAdoption("ses_a", "other")).toBe(false) + expect(consumeRenameAdoption("ses_a", "Cloud title")).toBe(true) + expect(consumeRenameAdoption("ses_a", "Cloud title")).toBe(false) + }) + + test("auto-title mark consumes only on exact title match", () => { + markAutoTitle("ses_b", "Auto title") + expect(consumeAutoTitle("ses_b", "other")).toBe(false) + expect(consumeAutoTitle("ses_b", "Auto title")).toBe(true) + expect(consumeAutoTitle("ses_b", "Auto title")).toBe(false) + }) + + test("rename and auto-title marks are independent per session", () => { + markRenameAdopted("ses_c", "R") + markAutoTitle("ses_c", "A") + expect(consumeAutoTitle("ses_c", "A")).toBe(true) + expect(consumeRenameAdoption("ses_c", "R")).toBe(true) + }) +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 3a7a44cf747f..06fd958587d3 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -469,6 +469,7 @@ describe("kilocode tool registry indexing", () => { KiloSessions.Service.of({ init: () => Effect.sync(() => calls.push("sessions")), sendAgentNotification: () => Effect.succeed({ ok: false as const, reason: "not_connected" }), + reportSessionTitle: () => Effect.succeed({ ok: false as const, reason: "not_connected" }), }), ) const bus = Layer.succeed( diff --git a/packages/opencode/test/kilocode/tool/notify-user.test.ts b/packages/opencode/test/kilocode/tool/notify-user.test.ts index 4113d3b0fc9e..9b98f70c85b2 100644 --- a/packages/opencode/test/kilocode/tool/notify-user.test.ts +++ b/packages/opencode/test/kilocode/tool/notify-user.test.ts @@ -92,6 +92,7 @@ describe("notify_user tool", () => { Layer.succeed(KiloSessions.Service, KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), })), Layer.succeed(Agent.Service, agents), Layer.succeed(Truncate.Service, truncate), @@ -113,6 +114,7 @@ describe("notify_user tool", () => { await expect(runNotifyTool({ message: "" }, { init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), })).rejects.toBeDefined() }) @@ -120,6 +122,7 @@ describe("notify_user tool", () => { await expect(runNotifyTool({ message: " \n " }, { init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), })).rejects.toBeDefined() }) @@ -127,6 +130,7 @@ describe("notify_user tool", () => { await expect(runNotifyTool({ message: "x".repeat(501) }, { init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), })).rejects.toBeDefined() }) @@ -139,6 +143,7 @@ describe("notify_user tool", () => { calls.push({ sessionID, input }) return { ok: true } }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: " hello world " }, sessions) @@ -153,6 +158,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_connected" }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: "hello" }, sessions) @@ -166,6 +172,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const send = spyOn(sessions, "sendAgentNotification") @@ -180,6 +187,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: false, reason: "http_500" }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: "hello" }, sessions) @@ -192,6 +200,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: false, reason: "not_bootstrapped" }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: "hello" }, sessions) @@ -206,6 +215,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: false, reason: "bootstrap_timeout" }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: "hello" }, sessions) @@ -219,6 +229,7 @@ describe("notify_user tool", () => { const sessions = KiloSessions.Service.of({ init: () => Effect.void, sendAgentNotification: () => Effect.succeed({ ok: true }), + reportSessionTitle: () => Effect.succeed({ ok: false, reason: "not_connected" }), }) const result = await runNotifyTool({ message: "ping" }, sessions) From cd250b10a65fd0a76ce00209dbfc81dcc35a08c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 16:58:29 +0200 Subject: [PATCH 02/18] =?UTF-8?q?test(opencode):=20prove=20cancel=E2=86=92?= =?UTF-8?q?reprompt=20reaches=20idle;=20lock=20exit=20survivor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 14 CLI prove-it at SessionPrompt level: cancel-when-idle, mid-stream, mid-tool, queued follow-up (deterministic queue wait), and abortIntakes all settle to idle and reprompt completes — no production hang found, no src change. Item 8: lock survivor session send_message after sibling exit_cli. --- .../kilocode/sessions/remote-sender.test.ts | 75 ++++++ packages/opencode/test/session/prompt.test.ts | 238 ++++++++++++++++++ 2 files changed, 313 insertions(+) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 482f121728f7..76f27bbd3dcb 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -3586,6 +3586,81 @@ describe("RemoteSender slash commands", () => { expect(sent).toEqual([{ type: "response", id: "req_rollback", error: "failed to exit session" }]) }) + // Item 8 locking gap: after exiting one of two attached sessions, the + // survivor must still accept send_message (and the host must not exit). + test("survivor session keeps accepting send_message after sibling exit_cli", async () => { + const { conn, sent } = fakeConn() + const exitID = SessionID.make("ses_exit") + const keepID = SessionID.make("ses_keep") + const owned = new Set([exitID, keepID]) + const cancelled: SessionID[] = [] + const detached: SessionID[] = [] + const calls: SessionPrompt.PromptInput[] = [] + let exitInvoked = false + + const sender = RemoteSender.create({ + conn, + directory: "/workspace/project-a", + log: nolog, + subscribe: fakeBus().subscribe, + session: { + get: async (id) => info(id), + children: async () => [], + }, + hasSession: (id) => owned.has(id), + cancelPrompt: async (id) => { + cancelled.push(id) + }, + detachSession: async (id) => { + detached.push(id) + owned.delete(id) + }, + ownedCount: () => owned.size, + remoteExit: { + get: () => async () => { + exitInvoked = true + }, + }, + prompt: prompts(calls), + provide: async (input: any) => input.fn(), + }) + + sender.handle({ + type: "command", + id: "req_exit_sibling", + command: "exit_cli", + sessionId: exitID, + data: { protocolVersion: 1 }, + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(cancelled).toEqual([exitID]) + expect(detached).toEqual([exitID]) + expect(owned.has(keepID)).toBe(true) + expect(owned.has(exitID)).toBe(false) + expect(exitInvoked).toBe(false) + expect(sent).toEqual([{ type: "response", id: "req_exit_sibling", result: {} }]) + + sender.handle({ + type: "command", + id: "req_survivor_send", + command: "send_message", + data: { + sessionID: keepID, + parts: [{ type: "text", text: "still here" }], + }, + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(calls).toHaveLength(1) + expect(calls[0]?.sessionID).toBe(keepID) + expect(sent).toContainEqual({ type: "response", id: "req_survivor_send", result: {} }) + }) + test("create_session forwards agent, model (with variant), and orgId metadata", async () => { const { conn, sent } = fakeConn() const createCalls: unknown[] = [] diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index f4cb5fcd10fc..7f1c5f4d266a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -42,6 +42,10 @@ import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { KiloSession } from "../../src/kilocode/session" +// kilocode_change start - Item 14 cancel→reprompt proof helpers +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" +import { KiloSessionPromptQueue } from "../../src/kilocode/session/prompt-queue" +// kilocode_change end import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" import { Suggestion } from "../../src/kilocode/suggestion" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -1246,6 +1250,240 @@ it.instance( 10_000, ) +// kilocode_change start - Item 14 CLI prove-it: cancel settles to Idle promptly, +// then the same session accepts a new prompt to completion. Covers idle, +// mid-stream, mid-tool, queued follow-up, and intake-abort paths that mobile +// stop→send depends on. + +it.instance( + "cancel when idle is a no-op and the next prompt runs to completion", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const run = yield* SessionRunState.Service + const chat = yield* sessions.create({ title: "Cancel idle then prompt" }) + + yield* prompt.cancel(chat.id).pipe(Effect.timeout("250 millis")) + expect((yield* status.get(chat.id)).type).toBe("idle") + const free = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(free)).toBe(true) + + yield* llm.text("after-idle-cancel") + const result = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "hello after idle cancel" }], + }) + .pipe(Effect.timeout("10 seconds")) + expect(result.info.role).toBe("assistant") + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 15_000, +) + +it.instance( + "cancel mid-stream reaches idle promptly then reprompt completes", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const run = yield* SessionRunState.Service + const chat = yield* sessions.create({ title: "Cancel stream then prompt" }) + yield* seed(chat.id) + yield* user(chat.id, "stream me") + + yield* llm.hang + const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + expect((yield* status.get(chat.id)).type).toBe("busy") + + yield* prompt.cancel(chat.id).pipe(Effect.timeout("1 second")) + const stop = yield* Fiber.await(first).pipe(Effect.timeout("2 seconds")) + expect(Exit.isSuccess(stop)).toBe(true) + expect((yield* status.get(chat.id)).type).toBe("idle") + const free = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(free)).toBe(true) + + yield* llm.text("reprompt-ok") + const second = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "send again" }], + }) + .pipe(Effect.timeout("10 seconds")) + expect(second.info.role).toBe("assistant") + if (second.info.role === "assistant") { + expect(second.info.error).toBeUndefined() + } + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 20_000, +) + +it.instance( + "cancel mid-tool reaches idle promptly then reprompt completes", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const run = yield* SessionRunState.Service + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = yield* hangUntilAborted(read) + yield* restore + + const chat = yield* sessions.create({ title: "Cancel tool then prompt" }) + yield* seed(chat.id) + yield* user(chat.id, "use a tool") + + // LLM asks for a hanging tool; cancel must abort the tool and free the runner. + yield* llm.tool("read", { filePath: "/tmp/item14-cancel-tool.txt" }) + const first = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* awaitWithTimeout(Deferred.await(ready), "tool never started", "10 seconds") + expect((yield* status.get(chat.id)).type).toBe("busy") + + yield* prompt.cancel(chat.id).pipe(Effect.timeout("1 second")) + const stop = yield* Fiber.await(first).pipe(Effect.timeout("5 seconds")) + expect(Exit.isSuccess(stop)).toBe(true) + yield* awaitWithTimeout(Deferred.await(aborted), "tool never aborted", "5 seconds") + expect((yield* status.get(chat.id)).type).toBe("idle") + const free = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(free)).toBe(true) + + yield* llm.text("after-tool-cancel") + const second = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "send after tool cancel" }], + }) + .pipe(Effect.timeout("10 seconds")) + expect(second.info.role).toBe("assistant") + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 30_000, +) + +it.instance( + "cancel drops a queued follow-up then a fresh prompt runs to completion", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const run = yield* SessionRunState.Service + const chat = yield* sessions.create({ title: "Cancel queued then prompt" }) + + // Hang the first turn so a second prompt is forced through the queue. + yield* llm.hang + const active = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "active turn" }], + }) + .pipe(Effect.forkChild) + yield* llm.wait(1) + expect((yield* status.get(chat.id)).type).toBe("busy") + + const queued = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "queued follow-up" }], + }) + .pipe(Effect.forkChild) + // Wait until the follow-up is actually on the queue (past intake) so cancel + // proves the queued-drop path, not abortIntakes of a still-intaking prompt. + yield* pollWithTimeout( + Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(chat.id) ? (true as const) : undefined)), + "follow-up prompt never queued behind the in-flight turn", + "3 seconds", + ) + + yield* prompt.cancel(chat.id).pipe(Effect.timeout("1 second")) + yield* Effect.all([Fiber.await(active), Fiber.await(queued)], { concurrency: "unbounded" }).pipe( + Effect.timeout("5 seconds"), + ) + // Only the first (interrupted) turn may have hit the LLM; the queued one must not. + expect(yield* llm.inputs.pipe(Effect.map((items) => items.length))).toBe(1) + expect(KiloSessionPromptQueue.hasFollowup(chat.id)).toBe(false) + expect((yield* status.get(chat.id)).type).toBe("idle") + const free = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(free)).toBe(true) + + yield* llm.text("fresh-after-queue-cancel") + const again = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [{ type: "text", text: "fresh prompt" }], + }) + .pipe(Effect.timeout("10 seconds")) + expect(again.info.role).toBe("assistant") + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + 30_000, +) + +it.instance( + "cancel aborts in-flight intake then a fresh prompt runs to completion", + () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const run = yield* SessionRunState.Service + const chat = yield* sessions.create({ title: "Cancel intake then prompt" }) + const sessionID = chat.id + + // Hold an intake fiber the way createUserMessage does; cancelTree must + // abort it via abortIntakes so the session is free for the next prompt. + const started = yield* Deferred.make() + const finished = yield* Deferred.make() + const intake = yield* KiloSessionPrompt.intake( + sessionID, + Effect.gen(function* () { + yield* Deferred.succeed(started, undefined) + return yield* Effect.never + }).pipe(Effect.ensuring(Deferred.succeed(finished, undefined).pipe(Effect.asVoid, Effect.ignore))), + ).pipe(Effect.exit, Effect.forkChild) + yield* Deferred.await(started).pipe(Effect.timeout("1 second")) + + yield* prompt.cancel(sessionID).pipe(Effect.timeout("1 second")) + // Intake work must settle (interrupt/cleanup) promptly after cancel. + yield* Deferred.await(finished).pipe(Effect.timeout("2 seconds")) + yield* Fiber.await(intake).pipe(Effect.timeout("2 seconds")) + expect((yield* status.get(sessionID)).type).toBe("idle") + const free = yield* run.assertNotBusy(sessionID).pipe(Effect.exit) + expect(Exit.isSuccess(free)).toBe(true) + + yield* llm.text("after-intake-cancel") + const result = yield* prompt + .prompt({ + sessionID, + agent: "build", + parts: [{ type: "text", text: "send after intake cancel" }], + }) + .pipe(Effect.timeout("10 seconds")) + expect(result.info.role).toBe("assistant") + expect((yield* status.get(sessionID)).type).toBe("idle") + }), + 20_000, +) +// kilocode_change end + unix( "cancel records MessageAbortedError on interrupted process", () => From 58b4528f583d8fe8d64b2add835ff19442a7d42c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 18:13:14 +0200 Subject: [PATCH 03/18] test(opencode): drop AppRuntime spy from create_session default test Satisfies check-opencode-promise-facades while still proving the production default forwards {agent, model, metadata} into Session.Service.create. --- .../test/kilocode/sessions/remote-sender.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 76f27bbd3dcb..44c0d308f411 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -3776,10 +3776,11 @@ describe("RemoteSender slash commands", () => { }) test("create_session production default forwards agent, model, metadata into Session.Service.create", async () => { - // Omits options.session.create so the production default path runs: - // AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))). - // Spy both seams: AppRuntime (unit suite has no full runtime) and - // Session.Service.use so we observe the exact input the default forwards. + // Omits options.session.create so the production default path runs + // (global runtime + Session.Service.use → svc.create(input)). + // Stub Session.Service.use so create yields a requirement-free Effect; + // the real global runtime then executes it without spying the runtime + // (that would trip the promise-facades allowlist). const { conn, sent } = fakeConn() const createCalls: unknown[] = [] const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" @@ -3794,9 +3795,6 @@ describe("RemoteSender slash commands", () => { time: { created: 0, updated: 0 }, } satisfies Session.Info - const { AppRuntime } = await import("../../../src/effect/app-runtime") - const runSpy = spyOn(AppRuntime, "runPromise").mockImplementation(((effect: Effect.Effect) => - Effect.runPromise(effect as Effect.Effect)) as typeof AppRuntime.runPromise) const useSpy = spyOn(Session.Service, "use").mockImplementation(((fn: (svc: Session.Interface) => unknown) => fn({ create: (input?: Parameters[0]) => { @@ -3855,7 +3853,6 @@ describe("RemoteSender slash commands", () => { ]) } finally { useSpy.mockRestore() - runSpy.mockRestore() } }) From 9e0b201d8ea5f06312dcae2eb3d47ae9cbbceb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 18:57:26 +0200 Subject: [PATCH 04/18] fix(opencode): bound rename marks, wire title report path, harden title tests Kilobot review on #12704: adoption/auto-title maps now carry timestamps, prune on write (60s TTL), and clear on Session.Event.Deleted (exported clear/clearAll); the Updated watcher calls the interface reportSessionTitle and fullSync passes preloaded info into meta(); ensureTitle's Kilo logic lives in kilocode/session/prompt.ts behind one kilocode_change call site; title tests poll instead of sleeping and lock mark-before-write plus clear-on-failure for real; meta() get-failure org fallback covered via the _metaForTests seam. --- .../src/kilo-sessions/kilo-sessions.ts | 31 +- .../src/kilo-sessions/rename-adoptions.ts | 56 ++- .../opencode/src/kilocode/session/prompt.ts | 23 + packages/opencode/src/session/prompt.ts | 19 +- .../sessions/ensure-title-mark.test.ts | 9 +- .../sessions/kilo-sessions-title.test.ts | 409 ++++++++---------- .../kilocode/sessions/remote-sender.test.ts | 22 +- .../sessions/rename-adoptions.test.ts | 46 +- 8 files changed, 341 insertions(+), 274 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index b3d5d03bd490..7d2d457a78bb 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -29,7 +29,7 @@ import { RemoteSender } from "@/kilo-sessions/remote-sender" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { AttachedState } from "@/kilo-sessions/attached-state" -import { consumeAutoTitle, consumeRenameAdoption } from "@/kilo-sessions/rename-adoptions" +import { clear as clearRenameMarks, consumeAutoTitle, consumeRenameAdoption } from "@/kilo-sessions/rename-adoptions" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" @@ -321,6 +321,15 @@ export namespace KiloSessions { Effect.gen(function* () { const config = yield* Config.Service const sessions = yield* Session.Service + + const reportSessionTitle = Effect.fn("KiloSessions.reportSessionTitle")(function* ( + sessionID: string, + title: string, + opts: { generated: boolean }, + ) { + return yield* Effect.promise(() => reportTitleChange(sessionID, title, opts.generated)) + }) + const state = yield* InstanceState.make( Effect.fn("KiloSessions.state")(function* (ctx) { if (ingestDisabled) return @@ -377,7 +386,13 @@ export namespace KiloSessions { if (prev === undefined) return if (consumeRenameAdoption(sessionID, session.title)) return const generated = consumeAutoTitle(sessionID, session.title) - await reportTitleChange(sessionID, session.title, generated) + // Production path goes through the Interface method (not private helper). + await Effect.runPromise(reportSessionTitle(sessionID, session.title, { generated })) + }) + watch(Session.Event.Deleted, (evt) => { + const sessionID = evt.properties.sessionID + knownTitles.delete(sessionID) + clearRenameMarks(sessionID) }) watch(MessageV2.Event.Updated, async (evt) => { await ingest.sync(evt.properties.info.sessionID, [{ type: "message", data: evt.properties.info }]) @@ -505,14 +520,6 @@ export namespace KiloSessions { ) }) - const reportSessionTitle = Effect.fn("KiloSessions.reportSessionTitle")(function* ( - sessionID: string, - title: string, - opts: { generated: boolean }, - ) { - return yield* Effect.promise(() => reportTitleChange(sessionID, title, opts.generated)) - }) - return Service.of({ init, sendAgentNotification, reportSessionTitle }) }), ) @@ -1178,7 +1185,7 @@ export namespace KiloSessions { await ingest.sync(sessionId, [ { type: "kilo_meta", - data: await meta(sessionId), + data: await meta(sessionId, session), }, { type: "session", @@ -1263,7 +1270,7 @@ export namespace KiloSessions { } } - /** Test seam: exercise meta()/getOrgId without a preloaded Session.Info (get-failure fallback). */ + /** Test seam: meta() without preloaded info (Session.get failure → env/auth fallback). */ export async function _metaForTests(sessionId?: string, info?: Session.Info | null) { return meta(sessionId, info) } diff --git a/packages/opencode/src/kilo-sessions/rename-adoptions.ts b/packages/opencode/src/kilo-sessions/rename-adoptions.ts index 9e8bfe636d6a..bb24dee42cf3 100644 --- a/packages/opencode/src/kilo-sessions/rename-adoptions.ts +++ b/packages/opencode/src/kilo-sessions/rename-adoptions.ts @@ -2,27 +2,65 @@ // (title broadcast + auto-title marking). Kept free of imports from either so // neither side needs a static import of the other. -const renames = new Map() -const autos = new Map() +/** Marks older than this are dropped on write. Generous vs the ~10s DO re-emit window. */ +export const MARK_TTL_MS = 60_000 + +type Entry = { title: string; time: number } + +const renames = new Map() +const autos = new Map() + +function prune(map: Map, now: number) { + for (const [id, entry] of map) { + if (now - entry.time > MARK_TTL_MS) map.delete(id) + } +} + +function mark(map: Map, sessionId: string, title: string) { + const now = Date.now() + prune(map, now) + map.set(sessionId, { title, time: now }) +} + +function consume(map: Map, sessionId: string, title: string): boolean { + const now = Date.now() + const entry = map.get(sessionId) + if (!entry) return false + if (now - entry.time > MARK_TTL_MS) { + map.delete(sessionId) + return false + } + if (entry.title !== title) return false + map.delete(sessionId) + return true +} export function markRenameAdopted(sessionId: string, title: string) { - renames.set(sessionId, title) + mark(renames, sessionId, title) } /** Consume a pending rename adoption when the title matches. */ export function consumeRenameAdoption(sessionId: string, title: string): boolean { - if (renames.get(sessionId) !== title) return false - renames.delete(sessionId) - return true + return consume(renames, sessionId, title) } export function markAutoTitle(sessionId: string, title: string) { - autos.set(sessionId, title) + mark(autos, sessionId, title) } /** Consume a pending auto-title mark when the title matches. */ export function consumeAutoTitle(sessionId: string, title: string): boolean { - if (autos.get(sessionId) !== title) return false + return consume(autos, sessionId, title) +} + +/** Drop both mark maps for one session (Deleted / test isolation). */ +export function clear(sessionId: string) { + renames.delete(sessionId) autos.delete(sessionId) - return true +} + +/** Drop every mark (tests). */ +export function clearAll() { + renames.clear() + autos.clear() } diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index a1fa29b80648..5240998223c4 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -28,6 +28,7 @@ import { MemoryMarker } from "@/kilocode/memory/marker" import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { KiloToolRegistry } from "@/kilocode/tool/registry" import CODE_SWITCH from "@/session/prompt/code-switch.txt" +import { consumeAutoTitle, markAutoTitle } from "@/kilo-sessions/rename-adoptions" export namespace KiloSessionPrompt { const modes = ["ask", "plan", "architect"] @@ -73,6 +74,28 @@ export namespace KiloSessionPrompt { return `title-${sessionID}` } + /** + * Auto-title write gate for ensureTitle: re-check default title and mark + * before setTitle. Returns true when the caller should call setTitle (mark + * already recorded). On setTitle failure call `clearAutoTitleMark`. + * K1: mark BEFORE write; consume on fail. + */ + export function prepareAutoTitle(input: { + sessionID: string + title: string + fresh: { title: string } | null | undefined + isDefaultTitle: (title: string) => boolean + }): boolean { + if (!input.fresh || !input.isDefaultTitle(input.fresh.title)) return false + markAutoTitle(input.sessionID, input.title) + return true + } + + /** Clear auto-title mark after a failed setTitle (pair with prepareAutoTitle). */ + export function clearAutoTitleMark(sessionID: string, title: string) { + consumeAutoTitle(sessionID, title) + } + function mode(name: string) { return name.toLowerCase() } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c0dfd5293601..20327817d072 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -85,8 +85,6 @@ import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { RepositoryCache } from "@opencode-ai/core/repository-cache" // kilocode_change -import { markAutoTitle, consumeAutoTitle } from "@/kilo-sessions/rename-adoptions" // kilocode_change - // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -317,15 +315,20 @@ export const layer = Layer.effect( .find((line) => line.length > 0) if (!cleaned) return const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned - // kilocode_change start - re-check default title + mark auto-title before setTitle - // so a mid-generation rename is not clobbered, and kilo-sessions can label the - // title POST as generated:true (mark before write — Updated publishes inside setTitle). + // kilocode_change start - auto-title mark/re-check owned by KiloSessionPrompt const fresh = yield* sessions.get(input.session.id).pipe(Effect.orElseSucceed(() => null)) - if (!fresh || !Session.isDefaultTitle(fresh.title)) return - markAutoTitle(input.session.id, t) + if ( + !KiloSessionPrompt.prepareAutoTitle({ + sessionID: input.session.id, + title: t, + fresh, + isDefaultTitle: Session.isDefaultTitle, + }) + ) + return yield* sessions.setTitle({ sessionID: input.session.id, title: t }).pipe( Effect.catchCause((cause) => { - consumeAutoTitle(input.session.id, t) + KiloSessionPrompt.clearAutoTitleMark(input.session.id, t) return Effect.logError("failed to generate title", { error: Cause.squash(cause) }) }), ) diff --git a/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts index 11a891b7f1fd..02f41b61d15e 100644 --- a/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts +++ b/packages/opencode/test/kilocode/sessions/ensure-title-mark.test.ts @@ -24,7 +24,7 @@ import { EventV2Bridge } from "../../../src/event-v2-bridge" import { Format } from "../../../src/format" import { Git } from "../../../src/git" import { Image } from "../../../src/image/image" -import { consumeAutoTitle } from "../../../src/kilo-sessions/rename-adoptions" +import { clearAll as clearRenameMarks, consumeAutoTitle, markAutoTitle } from "../../../src/kilo-sessions/rename-adoptions" import { KiloSessions } from "../../../src/kilo-sessions/kilo-sessions" import { LSP } from "../../../src/lsp/lsp" import { MCP } from "../../../src/mcp" @@ -158,7 +158,10 @@ const sessionWrapped = Layer.effect( Effect.gen(function* () { hooks.setTitleCalls.push(input) if (hooks.failSetTitle) { - // Die so ensureTitle's Effect.catchCause clears the auto-title mark. + // Mark must already be present (mark-before-write). Consume proves it, + // then re-mark so ensureTitle's catchCause still has something to clear. + expect(consumeAutoTitle(input.sessionID, input.title)).toBe(true) + markAutoTitle(input.sessionID, input.title) return yield* Effect.die(new Error("setTitle failed for test")) } return yield* inner.setTitle(input) @@ -293,6 +296,7 @@ function resetHooks() { hooks.titleStreamEntered = false hooks.failSetTitle = false hooks.setTitleCalls = [] + clearRenameMarks() } /** Match prompt.test.ts: turn a Deferred into a thenable for TestLLMServer.hold. */ @@ -406,6 +410,7 @@ it.instance( const final = yield* sessions.get(chat.id) expect(Session.isDefaultTitle(final.title)).toBe(true) expect(hooks.setTitleCalls.length).toBeGreaterThanOrEqual(1) + // Production catch must have cleared the re-mark left inside the failing setTitle. for (const call of hooks.setTitleCalls) { expect(consumeAutoTitle(chat.id, call.title)).toBe(false) } diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index ed030b3117fa..7a2f3bc4c316 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -7,6 +7,7 @@ import { GlobalBus } from "../../../src/bus/global" import type { Config } from "../../../src/config/config" import { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache" import { + clearAll as clearRenameMarks, consumeAutoTitle, consumeRenameAdoption, markAutoTitle, @@ -14,7 +15,7 @@ import { } from "../../../src/kilo-sessions/rename-adoptions" import { Session } from "../../../src/session/session" import { TestConfig } from "../../fixture/config" -import { testEffect } from "../../lib/effect" +import { pollWithTimeout, testEffect } from "../../lib/effect" import { TestInstance } from "../../fixture/fixture" const KiloSessions = (await import("../../../src/kilo-sessions/kilo-sessions")).KiloSessions @@ -42,12 +43,44 @@ const ORG_META = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" const ORG_ENV = "11111111-2222-4333-8444-555555555555" const ORG_AUTH = "99999999-8888-4777-8666-555555555555" +const ENV_KEYS = ["KILO_API_KEY", "KILO_SESSION_INGEST_URL", "KILO_ORG_ID", "KILO_AGENT_NOTIFICATION_TIMEOUT_MS"] as const + +/** Snapshot env keys we patch so afterEach can restore even if layer build fails. */ +const envSnap = new Map() +let fetchSpy: ReturnType | undefined + +function snapEnv() { + for (const key of ENV_KEYS) { + if (!envSnap.has(key)) envSnap.set(key, process.env[key]) + } +} + +function restoreEnv() { + for (const [key, value] of envSnap) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + envSnap.clear() +} + +function restoreFetch() { + fetchSpy?.mockRestore() + fetchSpy = undefined +} + beforeEach(() => { delete process.env.KILO_ORG_ID + clearRenameMarks() }) +// Safety net: restore fetch + env even when Effect.provide / layer construction fails +// before Effect.ensuring runs (finding 7). afterEach(() => { + restoreFetch() + restoreEnv() delete process.env.KILO_ORG_ID + clearRenameMarks() + reset("test-token") }) type Req = { method: string; path: string; body?: any } @@ -100,21 +133,55 @@ function emitUpdated(directory: string, sessionID: string, title: string) { }) } -// Ingest queue debounces ~1s; wait past that and drain. -const settle = Effect.gen(function* () { +function patchEnv(values: Record) { + snapEnv() + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } +} + +function installFetch(impl: typeof globalThis.fetch) { + restoreFetch() + fetchSpy = spyOn(globalThis, "fetch").mockImplementation(impl) + return fetchSpy +} + +/** Wait until title POSTs reach at least `n`. */ +function waitTitlePosts(requests: Req[], n: number, message: string) { + return pollWithTimeout( + Effect.sync(() => (titlePosts(requests).length >= n ? titlePosts(requests) : undefined)), + message, + "5 seconds", + ) +} + +/** After expecting `n` title POSTs, hold briefly and assert the count stays `n`. */ +function holdTitlePosts(requests: Req[], n: number) { + return Effect.gen(function* () { + const start = Date.now() + while (Date.now() - start < 200) { + expect(titlePosts(requests)).toHaveLength(n) + yield* Effect.sleep(20) + } + expect(titlePosts(requests)).toHaveLength(n) + }) +} + +/** Drain ingest after debounce; used when waiting on meta items. */ +const drainIngest = Effect.gen(function* () { yield* Effect.sleep(1200) yield* Effect.promise(() => KiloSessions.drainIngestForShutdown()) }) it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountId", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_ORG_ID = ORG_ENV + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_ORG_ID: ORG_ENV, + }) reset("test-token") return Effect.gen(function* () { @@ -130,14 +197,13 @@ it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountI accountId: ORG_AUTH, }) yield* kilo.init() - yield* Effect.sleep(30) // 1) metadata wins over env const withMeta = yield* sessions.create({ metadata: { orgId: ORG_META } }) yield* Effect.promise(() => KiloSessions.bootstrap(withMeta.id)) requests.length = 0 emitUpdated(instance.directory, withMeta.id, withMeta.title) - yield* settle + yield* drainIngest expect(metaItems(requests).some((m) => m.orgId === ORG_META)).toBe(true) // 2) env wins when metadata absent @@ -146,7 +212,7 @@ it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountI yield* Effect.promise(() => KiloSessions.bootstrap(plain.id)) requests.length = 0 emitUpdated(instance.directory, plain.id, plain.title) - yield* settle + yield* drainIngest expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) // 3) auth accountId when env cleared @@ -157,35 +223,21 @@ it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountI yield* Effect.promise(() => KiloSessions.bootstrap(authOnly.id)) requests.length = 0 emitUpdated(instance.directory, authOnly.id, authOnly.title) - yield* settle + yield* drainIngest expect(metaItems(requests).some((m) => m.orgId === ORG_AUTH)).toBe(true) - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove("kilo").pipe(Effect.orDie) - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - delete process.env.KILO_ORG_ID - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + + yield* auth.remove("kilo").pipe(Effect.orDie) + }).pipe(Effect.provide(layer())) }) it.instance("meta falls through invalid metadata orgId to env", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_ORG_ID = ORG_ENV + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_ORG_ID: ORG_ENV, + }) reset("test-token") return Effect.gen(function* () { @@ -193,64 +245,67 @@ it.instance("meta falls through invalid metadata orgId to env", () => { const sessions = yield* Session.Service const instance = yield* TestInstance yield* kilo.init() - yield* Effect.sleep(30) const bad = yield* sessions.create({ metadata: { orgId: "not-a-uuid" } }) yield* Effect.promise(() => KiloSessions.bootstrap(bad.id)) requests.length = 0 emitUpdated(instance.directory, bad.id, bad.title) - yield* settle + yield* drainIngest expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - delete process.env.KILO_ORG_ID - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) +}) + +it.instance("meta falls back to env when session row has no resolvable org", () => { + // Precedence only: real Updated → kilo_meta with a live session that has no + // orgId in metadata. Does not exercise Session.get failure (see next test). + const requests: Req[] = [] + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_ORG_ID: ORG_ENV, + }) + reset("test-token") + + return Effect.gen(function* () { + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + const instance = yield* TestInstance + yield* kilo.init() + + const plain = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(plain.id)) + yield* drainIngest + requests.length = 0 + emitUpdated(instance.directory, plain.id, plain.title) + yield* drainIngest + expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) + expect(metaItems(requests).every((m) => m.orgId !== ORG_META && m.orgId !== ORG_AUTH)).toBe(true) + }).pipe(Effect.provide(layer())) }) it.instance("meta falls back to env when Session.Service.get fails", () => { - const originalKey = process.env.KILO_API_KEY - process.env.KILO_ORG_ID = ORG_ENV + // meta(sessionId) with no preloaded info: resolveSessionOrg loads via the + // global runtime; unknown id → get rejects → .catch(() => null) → KILO_ORG_ID. + // Must not throw or drop the process-global org claim on a fetch blip. + // Production callers pass info; this path is API robustness when they omit it. + patchEnv({ KILO_ORG_ID: ORG_ENV }) clearInFlightCache("kilo-sessions:org") return Effect.gen(function* () { - // No preloaded info + unknown session id → resolveSessionOrg get fails → env. - const result = yield* Effect.promise(() => - KiloSessions._metaForTests("ses_missing_for_meta_get_fail"), - ) + const result = yield* Effect.promise(() => KiloSessions._metaForTests("ses_missing_for_meta_get_fail")) expect(result.orgId).toBe(ORG_ENV) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - delete process.env.KILO_ORG_ID - clearInFlightCache("kilo-sessions:org") - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) it.instance("title broadcast: auto-title posts generated true; custom posts generated false; adoption skips", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL - const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) reset("test-token") return Effect.gen(function* () { @@ -258,7 +313,6 @@ it.instance("title broadcast: auto-title posts generated true; custom posts gene const kilo = yield* KiloSessions.Service const sessions = yield* Session.Service yield* kilo.init() - yield* Effect.sleep(30) const created = yield* sessions.create({}) const id = created.id @@ -271,17 +325,14 @@ it.instance("title broadcast: auto-title posts generated true; custom posts gene // Created seeds knownTitles; same-title Updated is a no-op POST-wise. emitUpdated(instance.directory, id, defaultTitle) - yield* Effect.sleep(50) - expect(titlePosts(requests)).toHaveLength(0) + yield* holdTitlePosts(requests, 0) // Auto-title: mark then setTitle so Updated consumer sees generated:true. const auto = "Auto generated title" markAutoTitle(id, auto) yield* sessions.setTitle({ sessionID: id, title: auto }) - yield* Effect.sleep(300) { - const posts = titlePosts(requests) - expect(posts.length).toBeGreaterThanOrEqual(1) + const posts = yield* waitTitlePosts(requests, 1, "auto-title never POSTed") expect(posts[posts.length - 1].body).toEqual({ title: auto, generated: true }) expect(posts[posts.length - 1].path).toBe(`/api/session/${id}/title`) } @@ -289,64 +340,42 @@ it.instance("title broadcast: auto-title posts generated true; custom posts gene requests.length = 0 yield* sessions.setTitle({ sessionID: id, title: "Custom A" }) - yield* Effect.sleep(300) { - const posts = titlePosts(requests) - expect(posts.length).toBeGreaterThanOrEqual(1) + const posts = yield* waitTitlePosts(requests, 1, "custom A never POSTed") expect(posts[posts.length - 1].body).toEqual({ title: "Custom A", generated: false }) } requests.length = 0 yield* sessions.setTitle({ sessionID: id, title: "Custom B" }) - yield* Effect.sleep(300) { - const posts = titlePosts(requests) - expect(posts.length).toBeGreaterThanOrEqual(1) + const posts = yield* waitTitlePosts(requests, 1, "custom B never POSTed") expect(posts[posts.length - 1].body).toEqual({ title: "Custom B", generated: false }) } requests.length = 0 markRenameAdopted(id, "From cloud") yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) - yield* Effect.sleep(300) - expect(titlePosts(requests)).toHaveLength(0) + yield* holdTitlePosts(requests, 0) expect(consumeRenameAdoption(id, "From cloud")).toBe(false) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS - else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) it.instance( "title broadcast: same-title Updated consumes rename adoption (double session.renamed)", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL - const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) reset("test-token") return Effect.gen(function* () { const kilo = yield* KiloSessions.Service const sessions = yield* Session.Service yield* kilo.init() - yield* Effect.sleep(30) const created = yield* sessions.create({}) const id = created.id @@ -354,67 +383,42 @@ it.instance( yield* Effect.sleep(50) requests.length = 0 - // Cloud rename applied once: marks adoption + setTitle → Updated consumes mark, no POST. markRenameAdopted(id, "From cloud") yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) - yield* Effect.sleep(300) - expect(titlePosts(requests)).toHaveLength(0) + yield* holdTitlePosts(requests, 0) expect(consumeRenameAdoption(id, "From cloud")).toBe(false) - // C2 re-emits session.renamed (or setTitle same title): mark again, same-title Updated - // must still consume so a later real local rename is not swallowed. markRenameAdopted(id, "From cloud") yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) - yield* Effect.sleep(300) - expect(titlePosts(requests)).toHaveLength(0) + yield* holdTitlePosts(requests, 0) expect(consumeRenameAdoption(id, "From cloud")).toBe(false) - // Later local rename to the same title must POST (Decision 8 last-write-wins). requests.length = 0 yield* sessions.setTitle({ sessionID: id, title: "Local rename away" }) - yield* Effect.sleep(300) + yield* waitTitlePosts(requests, 1, "local rename away never POSTed") yield* sessions.setTitle({ sessionID: id, title: "From cloud" }) - yield* Effect.sleep(300) - const posts = titlePosts(requests) + const posts = yield* waitTitlePosts(requests, 2, "local rename back never POSTed") expect(posts.some((p) => p.body?.title === "From cloud" && p.body?.generated === false)).toBe(true) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS - else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }, 15_000, ) it.instance("title broadcast: first rename after create (rename-before-prompt) POSTs", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL - const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) reset("test-token") return Effect.gen(function* () { const kilo = yield* KiloSessions.Service const sessions = yield* Session.Service yield* kilo.init() - yield* Effect.sleep(30) - // create publishes Created (seeds knownTitles) then we rename before any prompt/Updated seed. const created = yield* sessions.create({}) const id = created.id yield* Effect.promise(() => KiloSessions.bootstrap(id)) @@ -422,82 +426,41 @@ it.instance("title broadcast: first rename after create (rename-before-prompt) P requests.length = 0 yield* sessions.setTitle({ sessionID: id, title: "Renamed before prompt" }) - yield* Effect.sleep(300) - const posts = titlePosts(requests) - expect(posts.length).toBeGreaterThanOrEqual(1) + const posts = yield* waitTitlePosts(requests, 1, "rename-before-prompt never POSTed") expect(posts[posts.length - 1].body).toEqual({ title: "Renamed before prompt", generated: false }) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS - else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) it.instance("title broadcast: first rename after restart seeds from list and POSTs", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL - const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS const requests: Req[] = [] - const request = spyOn(globalThis, "fetch").mockImplementation(mockFetch(requests)) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) reset("test-token") return Effect.gen(function* () { const sessions = yield* Session.Service - // Pre-existing session (process restart: no Created in this process). const existing = yield* sessions.create({}) const id = existing.id const priorTitle = existing.title - // Fresh KiloSessions init seeds knownTitles from sessions.list(). const kilo = yield* KiloSessions.Service yield* kilo.init() - yield* Effect.sleep(30) yield* Effect.promise(() => KiloSessions.bootstrap(id)) yield* Effect.sleep(50) requests.length = 0 yield* sessions.setTitle({ sessionID: id, title: "First rename after restart" }) - yield* Effect.sleep(300) - const posts = titlePosts(requests) - expect(posts.length).toBeGreaterThanOrEqual(1) + const posts = yield* waitTitlePosts(requests, 1, "first rename after restart never POSTed") expect(posts[posts.length - 1].body).toEqual({ title: "First rename after restart", generated: false }) - // Sanity: we actually changed away from the seeded title. expect(priorTitle).not.toBe("First rename after restart") - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS - else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) it.instance("reportSessionTitle goes through readiness and tolerates POST failure", () => { - const originalKey = process.env.KILO_API_KEY - const originalIngest = process.env.KILO_SESSION_INGEST_URL - const originalTimeout = process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS const requests: Req[] = [] let titleStatus = 500 const fetch: typeof globalThis.fetch = Object.assign( @@ -517,11 +480,12 @@ it.instance("reportSessionTitle goes through readiness and tolerates POST failur }, { preconnect: globalThis.fetch.preconnect }, ) - const request = spyOn(globalThis, "fetch").mockImplementation(fetch) - - process.env.KILO_API_KEY = "test-token" - process.env.KILO_SESSION_INGEST_URL = "https://ingest.kilosessions.ai" - process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = "5000" + installFetch(fetch) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) reset("test-token") return Effect.gen(function* () { @@ -537,25 +501,11 @@ it.instance("reportSessionTitle goes through readiness and tolerates POST failur titleStatus = 200 const ok = yield* kilo.reportSessionTitle(created.id, "Y", { generated: true }) expect(ok).toEqual({ ok: true }) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - if (originalIngest === undefined) delete process.env.KILO_SESSION_INGEST_URL - else process.env.KILO_SESSION_INGEST_URL = originalIngest - if (originalTimeout === undefined) delete process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS - else process.env.KILO_AGENT_NOTIFICATION_TIMEOUT_MS = originalTimeout - reset("test-token") - request.mockRestore() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) it.instance("reportSessionTitle reports not_connected when unauthenticated", () => { - const originalKey = process.env.KILO_API_KEY + patchEnv({ KILO_API_KEY: undefined }) delete process.env.KILO_API_KEY reset() @@ -563,14 +513,5 @@ it.instance("reportSessionTitle reports not_connected when unauthenticated", () const kilo = yield* KiloSessions.Service const result = yield* kilo.reportSessionTitle("ses_x", "T", { generated: false }) expect(result).toEqual({ ok: false, reason: "not_connected" }) - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (originalKey === undefined) delete process.env.KILO_API_KEY - else process.env.KILO_API_KEY = originalKey - reset() - }), - ), - Effect.provide(layer()), - ) + }).pipe(Effect.provide(layer())) }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index 44c0d308f411..e8683fe49e85 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -3861,11 +3861,8 @@ describe("RemoteSender slash commands", () => { const titles: { sessionID: string; title: string }[] = [] const warnings: unknown[][] = [] const sid = SessionID.make("ses_renamed") - const { markRenameAdopted: _mark, consumeRenameAdoption } = await import( - "../../../src/kilo-sessions/rename-adoptions" - ) - // Clear any leftover mark from prior tests by consuming a non-match then ensuring clean. - consumeRenameAdoption(sid, "__none__") + const { clear, consumeRenameAdoption } = await import("../../../src/kilo-sessions/rename-adoptions") + clear(sid) const sender = RemoteSender.create({ conn, @@ -3968,9 +3965,12 @@ describe("RemoteSender slash commands", () => { test("system session.renamed clears adoption mark when setTitle fails", async () => { const { conn } = fakeConn() const sid = SessionID.make("ses_rename_fail") - const { consumeRenameAdoption } = await import("../../../src/kilo-sessions/rename-adoptions") - consumeRenameAdoption(sid, "__none__") + const { clear, consumeRenameAdoption, markRenameAdopted } = await import( + "../../../src/kilo-sessions/rename-adoptions" + ) + clear(sid) + let sawMarkInsideSetTitle = false const sender = RemoteSender.create({ conn, directory: "/tmp", @@ -3984,6 +3984,11 @@ describe("RemoteSender slash commands", () => { }, children: async () => [], setTitle: async () => { + // Mark must already be present (mark-before-write). Consume proves it, + // then re-mark so the production catch path still has something to clear. + expect(consumeRenameAdoption(sid, "Cloud title")).toBe(true) + sawMarkInsideSetTitle = true + markRenameAdopted(sid, "Cloud title") throw new Error("setTitle boom") }, }, @@ -3998,7 +4003,8 @@ describe("RemoteSender slash commands", () => { await Promise.resolve() await Promise.resolve() - // Mark must be cleared so a later local write to the same title is not skipped. + expect(sawMarkInsideSetTitle).toBe(true) + // Production catch must clear the re-mark so a later local write is not skipped. expect(consumeRenameAdoption(sid, "Cloud title")).toBe(false) }) }) diff --git a/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts b/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts index ba2f6257f40b..c4141e7de213 100644 --- a/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts +++ b/packages/opencode/test/kilocode/sessions/rename-adoptions.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { + MARK_TTL_MS, + clear, + clearAll, consumeAutoTitle, consumeRenameAdoption, markAutoTitle, @@ -7,6 +10,9 @@ import { } from "../../../src/kilo-sessions/rename-adoptions" describe("rename-adoptions", () => { + beforeEach(() => clearAll()) + afterEach(() => clearAll()) + test("rename adoption consumes only on exact title match", () => { markRenameAdopted("ses_a", "Cloud title") expect(consumeRenameAdoption("ses_a", "other")).toBe(false) @@ -27,4 +33,42 @@ describe("rename-adoptions", () => { expect(consumeAutoTitle("ses_c", "A")).toBe(true) expect(consumeRenameAdoption("ses_c", "R")).toBe(true) }) + + test("clear drops both marks for one session", () => { + markRenameAdopted("ses_d", "R") + markAutoTitle("ses_d", "A") + markRenameAdopted("ses_e", "R2") + clear("ses_d") + expect(consumeRenameAdoption("ses_d", "R")).toBe(false) + expect(consumeAutoTitle("ses_d", "A")).toBe(false) + expect(consumeRenameAdoption("ses_e", "R2")).toBe(true) + }) + + test("stale marks expire after TTL", () => { + const now = Date.now() + const real = Date.now + try { + Date.now = () => now + markRenameAdopted("ses_ttl", "Old") + markAutoTitle("ses_ttl", "Auto") + Date.now = () => now + MARK_TTL_MS + 1 + expect(consumeRenameAdoption("ses_ttl", "Old")).toBe(false) + expect(consumeAutoTitle("ses_ttl", "Auto")).toBe(false) + } finally { + Date.now = real + } + }) + + test("fresh marks survive within TTL", () => { + const now = Date.now() + const real = Date.now + try { + Date.now = () => now + markRenameAdopted("ses_fresh", "Live") + Date.now = () => now + MARK_TTL_MS - 1 + expect(consumeRenameAdoption("ses_fresh", "Live")).toBe(true) + } finally { + Date.now = real + } + }) }) From 83888572f5e2a207c24448f2a942487d5b6a2660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Thu, 30 Jul 2026 19:38:57 +0200 Subject: [PATCH 05/18] fix(kilo-sessions): mark bookkeeping before ingest sync, AppRuntime, test cleanup Kilobot round 2 on #12704: consume rename/auto-title marks before the ingest.sync network hop so the 60s TTL spans only the in-process hop; call reportSessionTitle via AppRuntime.runPromise; auth cleanup back under Effect.ensuring; restore the upstream blank line in prompt.ts so the fork diff is only the kilocode_change call site. --- .../src/kilo-sessions/kilo-sessions.ts | 25 ++++---- packages/opencode/src/session/prompt.ts | 1 + .../sessions/kilo-sessions-title.test.ts | 58 ++++++++++--------- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 7d2d457a78bb..b612da3945f4 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -368,26 +368,29 @@ export namespace KiloSessions { const sessionID = evt.properties.sessionID const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null))) if (!session) return - await ingest.sync(sessionID, [ - { type: "kilo_meta", data: await meta(sessionID, session) }, - { type: "session", data: transport(session) }, - ]) + // Consume rename/auto-title marks before await ingest.sync so the 60s TTL + // spans only the in-process hop, not token resolution + network queueing. const prev = knownTitles.get(sessionID) knownTitles.set(sessionID, session.title) // Same-title Updated (setTitle no-op / double session.renamed): still // consume a matching rename adoption so the mark cannot stick and // swallow a later real local rename (Decision 8 last-write-wins). + let titleReport: { generated: boolean } | undefined if (prev === session.title) { consumeRenameAdoption(sessionID, session.title) - return + } else if (prev !== undefined && !consumeRenameAdoption(sessionID, session.title)) { + // Unseeded first sight (race before Created): record only, do not POST. + // After Created/list seed, any title change is a real change and POSTs. + titleReport = { generated: consumeAutoTitle(sessionID, session.title) } } - // Unseeded first sight (race before Created): record only, do not POST. - // After Created/list seed, any title change is a real change and POSTs. - if (prev === undefined) return - if (consumeRenameAdoption(sessionID, session.title)) return - const generated = consumeAutoTitle(sessionID, session.title) + await ingest.sync(sessionID, [ + { type: "kilo_meta", data: await meta(sessionID, session) }, + { type: "session", data: transport(session) }, + ]) + if (!titleReport) return // Production path goes through the Interface method (not private helper). - await Effect.runPromise(reportSessionTitle(sessionID, session.title, { generated })) + const { AppRuntime } = await import("@/effect/app-runtime") + await AppRuntime.runPromise(reportSessionTitle(sessionID, session.title, titleReport)) }) watch(Session.Event.Deleted, (evt) => { const sessionID = evt.properties.sessionID diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 20327817d072..4a8bc216406f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -85,6 +85,7 @@ import { SessionReminders } from "./reminders" import { SessionTools } from "./tools" import { LLMEvent } from "@opencode-ai/llm" import { RepositoryCache } from "@opencode-ai/core/repository-cache" // kilocode_change + // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index 7a2f3bc4c316..5c72d10c7c00 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -196,37 +196,39 @@ it.instance("meta org precedence: session metadata > KILO_ORG_ID > auth accountI expires: Date.now() + 60_000, accountId: ORG_AUTH, }) - yield* kilo.init() - - // 1) metadata wins over env - const withMeta = yield* sessions.create({ metadata: { orgId: ORG_META } }) - yield* Effect.promise(() => KiloSessions.bootstrap(withMeta.id)) - requests.length = 0 - emitUpdated(instance.directory, withMeta.id, withMeta.title) - yield* drainIngest - expect(metaItems(requests).some((m) => m.orgId === ORG_META)).toBe(true) + // Failure-safe: remove oauth even when an assertion fails mid-test (auth is + // not restored by afterEach — only fetch/env/rename marks are). + yield* Effect.gen(function* () { + yield* kilo.init() - // 2) env wins when metadata absent - requests.length = 0 - const plain = yield* sessions.create({}) - yield* Effect.promise(() => KiloSessions.bootstrap(plain.id)) - requests.length = 0 - emitUpdated(instance.directory, plain.id, plain.title) - yield* drainIngest - expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) + // 1) metadata wins over env + const withMeta = yield* sessions.create({ metadata: { orgId: ORG_META } }) + yield* Effect.promise(() => KiloSessions.bootstrap(withMeta.id)) + requests.length = 0 + emitUpdated(instance.directory, withMeta.id, withMeta.title) + yield* drainIngest + expect(metaItems(requests).some((m) => m.orgId === ORG_META)).toBe(true) - // 3) auth accountId when env cleared - delete process.env.KILO_ORG_ID - clearInFlightCache("kilo-sessions:org") - requests.length = 0 - const authOnly = yield* sessions.create({}) - yield* Effect.promise(() => KiloSessions.bootstrap(authOnly.id)) - requests.length = 0 - emitUpdated(instance.directory, authOnly.id, authOnly.title) - yield* drainIngest - expect(metaItems(requests).some((m) => m.orgId === ORG_AUTH)).toBe(true) + // 2) env wins when metadata absent + requests.length = 0 + const plain = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(plain.id)) + requests.length = 0 + emitUpdated(instance.directory, plain.id, plain.title) + yield* drainIngest + expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true) - yield* auth.remove("kilo").pipe(Effect.orDie) + // 3) auth accountId when env cleared + delete process.env.KILO_ORG_ID + clearInFlightCache("kilo-sessions:org") + requests.length = 0 + const authOnly = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(authOnly.id)) + requests.length = 0 + emitUpdated(instance.directory, authOnly.id, authOnly.title) + yield* drainIngest + expect(metaItems(requests).some((m) => m.orgId === ORG_AUTH)).toBe(true) + }).pipe(Effect.ensuring(auth.remove("kilo").pipe(Effect.orDie))) }).pipe(Effect.provide(layer())) }) From 9743b4e22c2bf8cfae3984ad6060aae2fa496a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 09:21:47 +0200 Subject: [PATCH 06/18] fix(kilo-sessions): keep title report self-healing if ingest.sync fails Advance knownTitles only after successful sync; restore consumed rename/ auto-title marks on failure so the next Updated can re-POST. IIFE keeps const-style outcome derivation. --- .../src/kilo-sessions/kilo-sessions.ts | 60 +++++++++++++------ 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index b612da3945f4..ff06b43aeb35 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -29,7 +29,13 @@ import { RemoteSender } from "@/kilo-sessions/remote-sender" import { RemoteProtocol } from "@/kilo-sessions/remote-protocol" import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" import { AttachedState } from "@/kilo-sessions/attached-state" -import { clear as clearRenameMarks, consumeAutoTitle, consumeRenameAdoption } from "@/kilo-sessions/rename-adoptions" +import { + clear as clearRenameMarks, + consumeAutoTitle, + consumeRenameAdoption, + markAutoTitle, + markRenameAdopted, +} from "@/kilo-sessions/rename-adoptions" import { SessionStatus } from "@/session/status" import { Telemetry } from "@kilocode/kilo-telemetry" import { Question } from "@/question" @@ -368,29 +374,45 @@ export namespace KiloSessions { const sessionID = evt.properties.sessionID const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null))) if (!session) return - // Consume rename/auto-title marks before await ingest.sync so the 60s TTL - // spans only the in-process hop, not token resolution + network queueing. + // Consume marks before the network hop so the 60s TTL does not span + // token resolution + ingest.sync. Advance knownTitles only after a + // successful sync so a rejected meta/sync leaves prev intact and the + // next Updated re-derives the POST. Restore consumed marks on failure + // so a cloud rename cannot echo and an auto-title keeps generated. const prev = knownTitles.get(sessionID) - knownTitles.set(sessionID, session.title) + const sameTitle = prev === session.title // Same-title Updated (setTitle no-op / double session.renamed): still - // consume a matching rename adoption so the mark cannot stick and - // swallow a later real local rename (Decision 8 last-write-wins). - let titleReport: { generated: boolean } | undefined - if (prev === session.title) { - consumeRenameAdoption(sessionID, session.title) - } else if (prev !== undefined && !consumeRenameAdoption(sessionID, session.title)) { - // Unseeded first sight (race before Created): record only, do not POST. - // After Created/list seed, any title change is a real change and POSTs. - titleReport = { generated: consumeAutoTitle(sessionID, session.title) } + // consume a matching rename adoption after sync so the mark cannot + // stick and swallow a later real local rename (Decision 8). + const outcome = ((): + | { kind: "same" } + | { kind: "seed" } + | { kind: "adopted" } + | { kind: "report"; generated: boolean } => { + if (sameTitle) return { kind: "same" } + if (prev === undefined) return { kind: "seed" } + if (consumeRenameAdoption(sessionID, session.title)) return { kind: "adopted" } + return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) } + })() + try { + await ingest.sync(sessionID, [ + { type: "kilo_meta", data: await meta(sessionID, session) }, + { type: "session", data: transport(session) }, + ]) + } catch (error) { + if (outcome.kind === "adopted") markRenameAdopted(sessionID, session.title) + else if (outcome.kind === "report" && outcome.generated) markAutoTitle(sessionID, session.title) + log.error("session updated ingest failed", { sessionID, error }) + return } - await ingest.sync(sessionID, [ - { type: "kilo_meta", data: await meta(sessionID, session) }, - { type: "session", data: transport(session) }, - ]) - if (!titleReport) return + if (outcome.kind === "same") consumeRenameAdoption(sessionID, session.title) + knownTitles.set(sessionID, session.title) + if (outcome.kind !== "report") return // Production path goes through the Interface method (not private helper). const { AppRuntime } = await import("@/effect/app-runtime") - await AppRuntime.runPromise(reportSessionTitle(sessionID, session.title, titleReport)) + await AppRuntime.runPromise( + reportSessionTitle(sessionID, session.title, { generated: outcome.generated }), + ) }) watch(Session.Event.Deleted, (evt) => { const sessionID = evt.properties.sessionID From a7834fe0452ef3f82667dfc0a272859e0d18a62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:03:02 +0200 Subject: [PATCH 07/18] fix(kilo-sessions): optimistic knownTitles with full title-path rollback Advance knownTitles before the network hop so concurrent Updated handlers see sameTitle and cannot POST the same title with a wrong generated flag. Restore prev + consumed marks when ingest.sync throws or reportSessionTitle returns not-ok, so the next Updated retries the full self-healing path. --- .../src/kilo-sessions/kilo-sessions.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index ff06b43aeb35..cefee6c90d91 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -375,10 +375,10 @@ export namespace KiloSessions { const session = await Effect.runPromise(sessions.get(sessionID).pipe(Effect.orElseSucceed(() => null))) if (!session) return // Consume marks before the network hop so the 60s TTL does not span - // token resolution + ingest.sync. Advance knownTitles only after a - // successful sync so a rejected meta/sync leaves prev intact and the - // next Updated re-derives the POST. Restore consumed marks on failure - // so a cloud rename cannot echo and an auto-title keeps generated. + // token resolution + ingest.sync. Advance knownTitles optimistically + // so a concurrent Updated sees sameTitle (no duplicate POST with a + // wrong generated flag). On ingest or title-POST failure restore + // prev + consumed marks so the next Updated re-derives and retries. const prev = knownTitles.get(sessionID) const sameTitle = prev === session.title // Same-title Updated (setTitle no-op / double session.renamed): still @@ -394,25 +394,37 @@ export namespace KiloSessions { if (consumeRenameAdoption(sessionID, session.title)) return { kind: "adopted" } return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) } })() + const restoreTitleState = () => { + if (prev === undefined) knownTitles.delete(sessionID) + else knownTitles.set(sessionID, prev) + if (outcome.kind === "adopted") markRenameAdopted(sessionID, session.title) + else if (outcome.kind === "report" && outcome.generated) markAutoTitle(sessionID, session.title) + } + knownTitles.set(sessionID, session.title) try { await ingest.sync(sessionID, [ { type: "kilo_meta", data: await meta(sessionID, session) }, { type: "session", data: transport(session) }, ]) } catch (error) { - if (outcome.kind === "adopted") markRenameAdopted(sessionID, session.title) - else if (outcome.kind === "report" && outcome.generated) markAutoTitle(sessionID, session.title) + restoreTitleState() log.error("session updated ingest failed", { sessionID, error }) return } if (outcome.kind === "same") consumeRenameAdoption(sessionID, session.title) - knownTitles.set(sessionID, session.title) if (outcome.kind !== "report") return // Production path goes through the Interface method (not private helper). const { AppRuntime } = await import("@/effect/app-runtime") - await AppRuntime.runPromise( + const reported = await AppRuntime.runPromise( reportSessionTitle(sessionID, session.title, { generated: outcome.generated }), ) + if (!reported.ok) { + restoreTitleState() + log.warn("session title report failed; will retry on next Updated", { + sessionID, + reason: reported.reason, + }) + } }) watch(Session.Event.Deleted, (evt) => { const sessionID = evt.properties.sessionID From 843690baa338d747c92212fca877b4d94daa5c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 10:03:43 +0200 Subject: [PATCH 08/18] style(kilo-sessions): prettier title Updated handler --- packages/opencode/src/kilo-sessions/kilo-sessions.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index cefee6c90d91..369d707171c1 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -1327,19 +1327,16 @@ export namespace KiloSessions { }) } - async function resolveSessionOrg( - sessionId?: string, - info?: Session.Info | null, - ): Promise { + async function resolveSessionOrg(sessionId?: string, info?: Session.Info | null): Promise { if (!sessionId) return undefined const resolved = info !== undefined ? info : await (async () => { const { AppRuntime } = await import("@/effect/app-runtime") - return AppRuntime.runPromise( - Session.Service.use((svc) => svc.get(SessionID.make(sessionId))), - ).catch(() => null) + return AppRuntime.runPromise(Session.Service.use((svc) => svc.get(SessionID.make(sessionId)))).catch( + () => null, + ) })() if (!resolved) return undefined const raw = resolved.metadata?.orgId From a3b5964ac80ca107b56ca929f42e33e5b3d6dccf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 13:18:42 +0200 Subject: [PATCH 09/18] fix(kilo-sessions): preserve newer title state --- .../src/kilo-sessions/kilo-sessions.ts | 44 ++- .../sessions/kilo-sessions-title.test.ts | 296 ++++++++++++++++++ 2 files changed, 333 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 369d707171c1..e052e22c2aa7 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -93,6 +93,18 @@ export namespace KiloSessions { const ttlMs = 10_000 + /** + * Classify an `http_` reason as a permanent (non-retryable) failure. + * 4xx client errors are permanent except 408 (Request Timeout) and 429 + * (Too Many Requests), which are transient and should be retried. + */ + function isPermanentHttpStatus(reason: string): boolean { + const match = reason.match(/^http_(\d+)$/) + if (!match) return false + const status = parseInt(match[1], 10) + return status >= 400 && status < 500 && status !== 408 && status !== 429 + } + function agentNotificationTimeoutMs(): number { const value = process.env["KILO_AGENT_NOTIFICATION_TIMEOUT_MS"] return value ? Number(value) : 10_000 @@ -395,8 +407,13 @@ export namespace KiloSessions { return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) } })() const restoreTitleState = () => { - if (prev === undefined) knownTitles.delete(sessionID) - else knownTitles.set(sessionID, prev) + // Only restore if this handler still owns the knownTitles slot. + // A concurrent handler may have advanced it to a newer title; in + // that case do not clobber it with this handler's stale prev. + if (knownTitles.get(sessionID) === session.title) { + if (prev === undefined) knownTitles.delete(sessionID) + else knownTitles.set(sessionID, prev) + } if (outcome.kind === "adopted") markRenameAdopted(sessionID, session.title) else if (outcome.kind === "report" && outcome.generated) markAutoTitle(sessionID, session.title) } @@ -419,11 +436,24 @@ export namespace KiloSessions { reportSessionTitle(sessionID, session.title, { generated: outcome.generated }), ) if (!reported.ok) { - restoreTitleState() - log.warn("session title report failed; will retry on next Updated", { - sessionID, - reason: reported.reason, - }) + // Permanent failures (non-retryable 4xx client errors) mean the + // server rejected this title definitively; keep the new title so + // the next same-title Updated is a no-op instead of retrying + // forever. Transient failures (5xx, 408, 429, network errors, + // not_connected) still restore + retry. + const isPermanent = isPermanentHttpStatus(reported.reason) + if (!isPermanent) restoreTitleState() + if (isPermanent) { + log.warn("session title report permanent failure; title preserved", { + sessionID, + reason: reported.reason, + }) + } else { + log.warn("session title report failed; will retry on next Updated", { + sessionID, + reason: reported.reason, + }) + } } }) watch(Session.Event.Deleted, (evt) => { diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index 5c72d10c7c00..7a3213b0ce78 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -517,3 +517,299 @@ it.instance("reportSessionTitle reports not_connected when unauthenticated", () expect(result).toEqual({ ok: false, reason: "not_connected" }) }).pipe(Effect.provide(layer())) }) + +it.instance( + "title report: permanent 4xx failure preserves title so same-title Updated does not re-POST", + () => { + const requests: Req[] = [] + const titleStatuses = new Map() + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: "remote-4xx", ingestPath: "/api/session/remote-4xx/ingest" }) + } + if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (path.endsWith("/title")) { + const sid = path.split("/api/session/")[1]?.split("/title")[0] + const status = titleStatuses.get(sid ?? "") ?? 200 + requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) + return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + installFetch(fetch) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + // First rename: server returns 4xx (permanent failure). + titleStatuses.set(id, 400) + yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) + const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") + expect(post1[0].body).toEqual({ title: "Custom Rename", generated: false }) + + // Same title again: should NOT re-POST (knownTitles preserved the title + // because 4xx is permanent, so sameTitle is true). + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) + yield* holdTitlePosts(requests, 0) + + // Different title: should POST again (title changed). + titleStatuses.set(id, 200) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Second Rename" }) + const post2 = yield* waitTitlePosts(requests, 1, "second title POST never fired") + expect(post2[0].body).toEqual({ title: "Second Rename", generated: false }) + }).pipe(Effect.provide(layer())) + }, +) + +it.instance( + "title report: transient 5xx failure restores title so same-title Updated retries", + () => { + const requests: Req[] = [] + const titleStatuses = new Map() + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: "remote-5xx", ingestPath: "/api/session/remote-5xx/ingest" }) + } + if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (path.endsWith("/title")) { + const sid = path.split("/api/session/")[1]?.split("/title")[0] + const status = titleStatuses.get(sid ?? "") ?? 200 + requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) + return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + installFetch(fetch) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + // First rename: server returns 5xx (transient failure). + titleStatuses.set(id, 500) + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") + expect(post1[0].body).toEqual({ title: "Will retry", generated: false }) + + // Same title again: SHOULD re-POST because 5xx is transient (title was + // rolled back, so the watcher treats this as a new title change). + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post2 = yield* waitTitlePosts(requests, 1, "retry POST never fired") + expect(post2[0].body).toEqual({ title: "Will retry", generated: false }) + + // Now succeed. + titleStatuses.set(id, 200) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post3 = yield* waitTitlePosts(requests, 1, "success POST never fired") + expect(post3[0].body).toEqual({ title: "Will retry", generated: false }) + }).pipe(Effect.provide(layer())) + }, +) + +it.instance( + "title report: transient 408/429 restores title so same-title Updated retries", + () => { + const requests: Req[] = [] + const titleStatuses = new Map() + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: "remote-4xxr", ingestPath: "/api/session/remote-4xxr/ingest" }) + } + if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (path.endsWith("/title")) { + const sid = path.split("/api/session/")[1]?.split("/title")[0] + const status = titleStatuses.get(sid ?? "") ?? 200 + requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) + return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + installFetch(fetch) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + // 408 Request Timeout — transient, must restore + retry. + titleStatuses.set(id, 408) + yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) + const post408 = yield* waitTitlePosts(requests, 1, "408 title POST never fired") + expect(post408[0].body).toEqual({ title: "Retry 408", generated: false }) + + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) + const post408b = yield* waitTitlePosts(requests, 1, "408 retry POST never fired") + expect(post408b[0].body).toEqual({ title: "Retry 408", generated: false }) + + // 429 Too Many Requests — transient, must restore + retry. + titleStatuses.set(id, 429) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) + const post429 = yield* waitTitlePosts(requests, 1, "429 title POST never fired") + expect(post429[0].body).toEqual({ title: "Retry 429", generated: false }) + + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) + const post429b = yield* waitTitlePosts(requests, 1, "429 retry POST never fired") + expect(post429b[0].body).toEqual({ title: "Retry 429", generated: false }) + }).pipe(Effect.provide(layer())) + }, +) + +it.instance( + "title report: stale handler A does not clobber handler B's knownTitles advance on A's POST failure", + () => { + const requests: Req[] = [] + let releaseBlockedPost: (() => void) | undefined + let postCount = 0 + const titleStatuses = new Map() + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: "remote-race", ingestPath: "/api/session/remote-race/ingest" }) + } + if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (path.endsWith("/title")) { + postCount++ + const sid = path.split("/api/session/")[1]?.split("/title")[0] + const status = titleStatuses.get(sid ?? "") ?? 200 + const body = init?.body ? JSON.parse(init.body as string) : undefined + requests.push({ method: "POST", path, body }) + // Block the first title POST (handler A) on a promise gate. + // Second POST (handler B) proceeds immediately. + if (postCount === 1) { + await new Promise((resolve) => { + releaseBlockedPost = resolve + }) + } + return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + installFetch(fetch) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 + + // Handler A: setTitle("Title A") — POST blocks on the promise gate. + // knownTitles advances to "Title A" synchronously before the gate. + titleStatuses.set(id, 500) // A's POST will fail after release + yield* sessions.setTitle({ sessionID: id, title: "Title A" }) + + // Wait for handler A's POST to hit the blocked gate (its request is already pushed). + yield* waitTitlePosts(requests, 1, "title A POST never appeared") + + // Handler B: setTitle("Title B") — knownTitles advances to "Title B", + // ingest.sync yields between knownTitles.set and the fetch call, letting + // B interleave. B's POST (postCount=2) proceeds unblocked. + titleStatuses.set(id, 200) // B's POST succeeds + yield* sessions.setTitle({ sessionID: id, title: "Title B" }) + + // Wait for B's unblocked POST. + yield* waitTitlePosts(requests, 2, "title B POST never appeared") + expect(requests.some((r) => r.path.endsWith("/title") && r.body?.title === "Title B")).toBe(true) + + // Release handler A's blocked POST → returns 500. + // restoreTitleState guard: knownTitles.get(id) === "Title B" !== "Title A" + // (the session.title in A's closure), so no clobbering occurs. + releaseBlockedPost?.() + yield* Effect.sleep(200) + + // Verify: same-title Updated for "Title B" should NOT re-POST + // because knownTitles still has "Title B" and was not clobbered. + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Title B" }) + yield* holdTitlePosts(requests, 0) + }).pipe(Effect.provide(layer())) + }, + 15_000, +) From 172a8eabd9d87db16ff41b6645dfda45b497c7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 14:25:56 +0200 Subject: [PATCH 10/18] refactor(kilo-sessions): simplify title reporting tests --- .../src/kilo-sessions/kilo-sessions.ts | 2 +- .../sessions/kilo-sessions-title.test.ts | 337 ++++++++---------- 2 files changed, 150 insertions(+), 189 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e052e22c2aa7..e7a1c72af5cb 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -442,13 +442,13 @@ export namespace KiloSessions { // forever. Transient failures (5xx, 408, 429, network errors, // not_connected) still restore + retry. const isPermanent = isPermanentHttpStatus(reported.reason) - if (!isPermanent) restoreTitleState() if (isPermanent) { log.warn("session title report permanent failure; title preserved", { sessionID, reason: reported.reason, }) } else { + restoreTitleState() log.warn("session title report failed; will retry on next Updated", { sessionID, reason: reported.reason, diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index 7a3213b0ce78..f97b7f700eb2 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -43,7 +43,12 @@ const ORG_META = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" const ORG_ENV = "11111111-2222-4333-8444-555555555555" const ORG_AUTH = "99999999-8888-4777-8666-555555555555" -const ENV_KEYS = ["KILO_API_KEY", "KILO_SESSION_INGEST_URL", "KILO_ORG_ID", "KILO_AGENT_NOTIFICATION_TIMEOUT_MS"] as const +const ENV_KEYS = [ + "KILO_API_KEY", + "KILO_SESSION_INGEST_URL", + "KILO_ORG_ID", + "KILO_AGENT_NOTIFICATION_TIMEOUT_MS", +] as const /** Snapshot env keys we patch so afterEach can restore even if layer build fails. */ const envSnap = new Map() @@ -122,6 +127,31 @@ function mockFetch(requests: Req[]) { ) as typeof globalThis.fetch } +/** Create a fetch stub that routes /user, /session, /ingest, and /title endpoints. + * Title status is resolved from `titleStatuses` keyed by session id. */ +function titleTestFetch(requests: Req[], titleStatuses: Map, sessionId: string) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const path = new URL(url).pathname + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + requests.push({ method: "POST", path }) + return Response.json({ id: sessionId, ingestPath: `/api/session/${sessionId}/ingest` }) + } + if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) + if (path.endsWith("/title")) { + const sid = path.split("/api/session/")[1]?.split("/title")[0] + const status = titleStatuses.get(sid ?? "") ?? 200 + requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) + return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) as typeof globalThis.fetch +} + function emitUpdated(directory: string, sessionID: string, title: string) { GlobalBus.emit("event", { directory, @@ -518,211 +548,142 @@ it.instance("reportSessionTitle reports not_connected when unauthenticated", () }).pipe(Effect.provide(layer())) }) -it.instance( - "title report: permanent 4xx failure preserves title so same-title Updated does not re-POST", - () => { - const requests: Req[] = [] - const titleStatuses = new Map() - const fetch: typeof globalThis.fetch = Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) - const path = new URL(url).pathname - if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) - if (url.endsWith("/api/session")) { - requests.push({ method: "POST", path }) - return Response.json({ id: "remote-4xx", ingestPath: "/api/session/remote-4xx/ingest" }) - } - if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) - if (path.endsWith("/title")) { - const sid = path.split("/api/session/")[1]?.split("/title")[0] - const status = titleStatuses.get(sid ?? "") ?? 200 - requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) - return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) - } - return new Response("{}", { status: 200 }) - }, - { preconnect: globalThis.fetch.preconnect }, - ) - installFetch(fetch) - patchEnv({ - KILO_API_KEY: "test-token", - KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", - KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", - }) - reset("test-token") +it.instance("title report: permanent 4xx failure preserves title so same-title Updated does not re-POST", () => { + const requests: Req[] = [] + const titleStatuses = new Map() + installFetch(titleTestFetch(requests, titleStatuses, "remote-4xx")) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") - return Effect.gen(function* () { - const instance = yield* TestInstance - const kilo = yield* KiloSessions.Service - const sessions = yield* Session.Service - yield* kilo.init() + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() - const created = yield* sessions.create({}) - const id = created.id - yield* Effect.promise(() => KiloSessions.bootstrap(id)) - yield* Effect.sleep(50) - requests.length = 0 + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 - // First rename: server returns 4xx (permanent failure). - titleStatuses.set(id, 400) - yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) - const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") - expect(post1[0].body).toEqual({ title: "Custom Rename", generated: false }) + // First rename: server returns 4xx (permanent failure). + titleStatuses.set(id, 400) + yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) + const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") + expect(post1[0].body).toEqual({ title: "Custom Rename", generated: false }) - // Same title again: should NOT re-POST (knownTitles preserved the title - // because 4xx is permanent, so sameTitle is true). - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) - yield* holdTitlePosts(requests, 0) + // Same title again: should NOT re-POST (knownTitles preserved the title + // because 4xx is permanent, so sameTitle is true). + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Custom Rename" }) + yield* holdTitlePosts(requests, 0) - // Different title: should POST again (title changed). - titleStatuses.set(id, 200) - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Second Rename" }) - const post2 = yield* waitTitlePosts(requests, 1, "second title POST never fired") - expect(post2[0].body).toEqual({ title: "Second Rename", generated: false }) - }).pipe(Effect.provide(layer())) - }, -) + // Different title: should POST again (title changed). + titleStatuses.set(id, 200) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Second Rename" }) + const post2 = yield* waitTitlePosts(requests, 1, "second title POST never fired") + expect(post2[0].body).toEqual({ title: "Second Rename", generated: false }) + }).pipe(Effect.provide(layer())) +}) -it.instance( - "title report: transient 5xx failure restores title so same-title Updated retries", - () => { - const requests: Req[] = [] - const titleStatuses = new Map() - const fetch: typeof globalThis.fetch = Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) - const path = new URL(url).pathname - if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) - if (url.endsWith("/api/session")) { - requests.push({ method: "POST", path }) - return Response.json({ id: "remote-5xx", ingestPath: "/api/session/remote-5xx/ingest" }) - } - if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) - if (path.endsWith("/title")) { - const sid = path.split("/api/session/")[1]?.split("/title")[0] - const status = titleStatuses.get(sid ?? "") ?? 200 - requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) - return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) - } - return new Response("{}", { status: 200 }) - }, - { preconnect: globalThis.fetch.preconnect }, - ) - installFetch(fetch) - patchEnv({ - KILO_API_KEY: "test-token", - KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", - KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", - }) - reset("test-token") +it.instance("title report: transient 5xx failure restores title so same-title Updated retries", () => { + const requests: Req[] = [] + const titleStatuses = new Map() + installFetch(titleTestFetch(requests, titleStatuses, "remote-5xx")) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") - return Effect.gen(function* () { - const instance = yield* TestInstance - const kilo = yield* KiloSessions.Service - const sessions = yield* Session.Service - yield* kilo.init() + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() - const created = yield* sessions.create({}) - const id = created.id - yield* Effect.promise(() => KiloSessions.bootstrap(id)) - yield* Effect.sleep(50) - requests.length = 0 + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 - // First rename: server returns 5xx (transient failure). - titleStatuses.set(id, 500) - yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) - const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") - expect(post1[0].body).toEqual({ title: "Will retry", generated: false }) + // First rename: server returns 5xx (transient failure). + titleStatuses.set(id, 500) + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post1 = yield* waitTitlePosts(requests, 1, "first title POST never fired") + expect(post1[0].body).toEqual({ title: "Will retry", generated: false }) - // Same title again: SHOULD re-POST because 5xx is transient (title was - // rolled back, so the watcher treats this as a new title change). - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) - const post2 = yield* waitTitlePosts(requests, 1, "retry POST never fired") - expect(post2[0].body).toEqual({ title: "Will retry", generated: false }) + // Same title again: SHOULD re-POST because 5xx is transient (title was + // rolled back, so the watcher treats this as a new title change). + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post2 = yield* waitTitlePosts(requests, 1, "retry POST never fired") + expect(post2[0].body).toEqual({ title: "Will retry", generated: false }) - // Now succeed. - titleStatuses.set(id, 200) - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) - const post3 = yield* waitTitlePosts(requests, 1, "success POST never fired") - expect(post3[0].body).toEqual({ title: "Will retry", generated: false }) - }).pipe(Effect.provide(layer())) - }, -) + // Now succeed. + titleStatuses.set(id, 200) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Will retry" }) + const post3 = yield* waitTitlePosts(requests, 1, "success POST never fired") + expect(post3[0].body).toEqual({ title: "Will retry", generated: false }) + }).pipe(Effect.provide(layer())) +}) -it.instance( - "title report: transient 408/429 restores title so same-title Updated retries", - () => { - const requests: Req[] = [] - const titleStatuses = new Map() - const fetch: typeof globalThis.fetch = Object.assign( - async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input) - const path = new URL(url).pathname - if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) - if (url.endsWith("/api/session")) { - requests.push({ method: "POST", path }) - return Response.json({ id: "remote-4xxr", ingestPath: "/api/session/remote-4xxr/ingest" }) - } - if (path.endsWith("/ingest")) return new Response("{}", { status: 200 }) - if (path.endsWith("/title")) { - const sid = path.split("/api/session/")[1]?.split("/title")[0] - const status = titleStatuses.get(sid ?? "") ?? 200 - requests.push({ method: "POST", path, body: init?.body ? JSON.parse(init.body as string) : undefined }) - return new Response(status === 200 ? '{"ok":true}' : "fail", { status }) - } - return new Response("{}", { status: 200 }) - }, - { preconnect: globalThis.fetch.preconnect }, - ) - installFetch(fetch) - patchEnv({ - KILO_API_KEY: "test-token", - KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", - KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", - }) - reset("test-token") +it.instance("title report: transient 408/429 restores title so same-title Updated retries", () => { + const requests: Req[] = [] + const titleStatuses = new Map() + installFetch(titleTestFetch(requests, titleStatuses, "remote-4xxr")) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") - return Effect.gen(function* () { - const instance = yield* TestInstance - const kilo = yield* KiloSessions.Service - const sessions = yield* Session.Service - yield* kilo.init() + return Effect.gen(function* () { + const instance = yield* TestInstance + const kilo = yield* KiloSessions.Service + const sessions = yield* Session.Service + yield* kilo.init() - const created = yield* sessions.create({}) - const id = created.id - yield* Effect.promise(() => KiloSessions.bootstrap(id)) - yield* Effect.sleep(50) - requests.length = 0 + const created = yield* sessions.create({}) + const id = created.id + yield* Effect.promise(() => KiloSessions.bootstrap(id)) + yield* Effect.sleep(50) + requests.length = 0 - // 408 Request Timeout — transient, must restore + retry. - titleStatuses.set(id, 408) - yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) - const post408 = yield* waitTitlePosts(requests, 1, "408 title POST never fired") - expect(post408[0].body).toEqual({ title: "Retry 408", generated: false }) + // 408 Request Timeout — transient, must restore + retry. + titleStatuses.set(id, 408) + yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) + const post408 = yield* waitTitlePosts(requests, 1, "408 title POST never fired") + expect(post408[0].body).toEqual({ title: "Retry 408", generated: false }) - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) - const post408b = yield* waitTitlePosts(requests, 1, "408 retry POST never fired") - expect(post408b[0].body).toEqual({ title: "Retry 408", generated: false }) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 408" }) + const post408b = yield* waitTitlePosts(requests, 1, "408 retry POST never fired") + expect(post408b[0].body).toEqual({ title: "Retry 408", generated: false }) - // 429 Too Many Requests — transient, must restore + retry. - titleStatuses.set(id, 429) - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) - const post429 = yield* waitTitlePosts(requests, 1, "429 title POST never fired") - expect(post429[0].body).toEqual({ title: "Retry 429", generated: false }) + // 429 Too Many Requests — transient, must restore + retry. + titleStatuses.set(id, 429) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) + const post429 = yield* waitTitlePosts(requests, 1, "429 title POST never fired") + expect(post429[0].body).toEqual({ title: "Retry 429", generated: false }) - requests.length = 0 - yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) - const post429b = yield* waitTitlePosts(requests, 1, "429 retry POST never fired") - expect(post429b[0].body).toEqual({ title: "Retry 429", generated: false }) - }).pipe(Effect.provide(layer())) - }, -) + requests.length = 0 + yield* sessions.setTitle({ sessionID: id, title: "Retry 429" }) + const post429b = yield* waitTitlePosts(requests, 1, "429 retry POST never fired") + expect(post429b[0].body).toEqual({ title: "Retry 429", generated: false }) + }).pipe(Effect.provide(layer())) +}) it.instance( "title report: stale handler A does not clobber handler B's knownTitles advance on A's POST failure", From f108af2ad0a30288a25a0e37d14cba507251595c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 18:16:09 +0200 Subject: [PATCH 11/18] fix(kilo-sessions): report unseeded title updates --- .../src/kilo-sessions/kilo-sessions.ts | 3 +- .../sessions/kilo-sessions-title.test.ts | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e7a1c72af5cb..19416045d4ad 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -398,11 +398,10 @@ export namespace KiloSessions { // stick and swallow a later real local rename (Decision 8). const outcome = ((): | { kind: "same" } - | { kind: "seed" } | { kind: "adopted" } | { kind: "report"; generated: boolean } => { if (sameTitle) return { kind: "same" } - if (prev === undefined) return { kind: "seed" } + if (prev === undefined) return { kind: "report", generated: false } if (consumeRenameAdoption(sessionID, session.title)) return { kind: "adopted" } return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) } })() diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index f97b7f700eb2..a1ccbaf00437 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -14,6 +14,8 @@ import { markRenameAdopted, } from "../../../src/kilo-sessions/rename-adoptions" import { Session } from "../../../src/session/session" +import { SessionID } from "../../../src/session/schema" +import { ProjectV2 } from "@opencode-ai/core/project" import { TestConfig } from "../../fixture/config" import { pollWithTimeout, testEffect } from "../../lib/effect" import { TestInstance } from "../../fixture/fixture" @@ -774,3 +776,75 @@ it.instance( }, 15_000, ) + +it.instance( + "title report: unseeded session (sessions.list() misses it, prev undefined) reports rename with generated:false", + () => { + const requests: Req[] = [] + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + // Mock Session.Service: list() returns empty (simulates session beyond the + // default 100-row limit after restart). get() returns the session with a + // title that differs from any previous seed, so prev === undefined triggers + // a report with generated:false instead of the old silent seed/no-op. + const mockSessionLayer = Layer.mock(Session.Service, { + list: () => Effect.succeed([]), + get: (sid: SessionID) => + Effect.succeed({ + id: sid, + title: "Renamed Title", + slug: "slug-unseeded", + projectID: ProjectV2.ID.make("proj-unseeded"), + directory: "/tmp/unseeded-test", + version: "test", + permission: {}, + time: { created: 0, updated: 0 }, + } as Session.Info), + create: () => + Effect.succeed({ + id: SessionID.make("ses_unseeded_title_test"), + title: "Default Title", + slug: "slug-unseeded", + projectID: ProjectV2.ID.make("proj-unseeded"), + directory: "/tmp/unseeded-test", + version: "test", + permission: {}, + time: { created: 0, updated: 0 }, + } as Session.Info), + setTitle: () => Effect.void, + }) + + const customLayer = KiloSessions.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(mockSessionLayer), + Layer.provide(TestConfig.layer({})), + ) + + return Effect.gen(function* () { + const instance = yield* TestInstance + const sessions = yield* Session.Service + const kilo = yield* KiloSessions.Service + yield* kilo.init() + + // Mock create() does not fire Session.Event.Created, so knownTitles is + // never seeded for this session. list() returns empty as well. + const created = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(created.id)) + yield* Effect.sleep(50) + requests.length = 0 + + // Emit Updated — handler calls sessions.get(id) which returns "Renamed Title". + // knownTitles has no entry → prev === undefined → report, generated:false. + emitUpdated(instance.directory, created.id, "Renamed Title") + const posts = yield* waitTitlePosts(requests, 1, "title POST never fired for unseeded session") + expect(posts[posts.length - 1].body).toEqual({ title: "Renamed Title", generated: false }) + }).pipe(Effect.provide(customLayer)) + }, + 15_000, +) From de91b69898640758e644c49e929cf444ba274536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 18:32:27 +0200 Subject: [PATCH 12/18] fix(kilo-sessions): consume unseeded title marks --- packages/opencode/src/kilo-sessions/kilo-sessions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 19416045d4ad..00c25bd9f071 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -401,7 +401,9 @@ export namespace KiloSessions { | { kind: "adopted" } | { kind: "report"; generated: boolean } => { if (sameTitle) return { kind: "same" } - if (prev === undefined) return { kind: "report", generated: false } + // Consume marks before the network hop so the 60s TTL does not span + // token resolution + ingest.sync. Checks run even when prev is + // unknown — an unseeded mark must not leak past this handler. if (consumeRenameAdoption(sessionID, session.title)) return { kind: "adopted" } return { kind: "report", generated: consumeAutoTitle(sessionID, session.title) } })() From 14e9907d21cd5f9e278005d9c6250aecb2311161 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 18:54:54 +0200 Subject: [PATCH 13/18] test(kilo-sessions): cover unseeded title marks --- .../sessions/kilo-sessions-title.test.ts | 151 ++++++++++++++---- 1 file changed, 120 insertions(+), 31 deletions(-) diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index a1ccbaf00437..2eed26ee0c3d 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -789,37 +789,7 @@ it.instance( }) reset("test-token") - // Mock Session.Service: list() returns empty (simulates session beyond the - // default 100-row limit after restart). get() returns the session with a - // title that differs from any previous seed, so prev === undefined triggers - // a report with generated:false instead of the old silent seed/no-op. - const mockSessionLayer = Layer.mock(Session.Service, { - list: () => Effect.succeed([]), - get: (sid: SessionID) => - Effect.succeed({ - id: sid, - title: "Renamed Title", - slug: "slug-unseeded", - projectID: ProjectV2.ID.make("proj-unseeded"), - directory: "/tmp/unseeded-test", - version: "test", - permission: {}, - time: { created: 0, updated: 0 }, - } as Session.Info), - create: () => - Effect.succeed({ - id: SessionID.make("ses_unseeded_title_test"), - title: "Default Title", - slug: "slug-unseeded", - projectID: ProjectV2.ID.make("proj-unseeded"), - directory: "/tmp/unseeded-test", - version: "test", - permission: {}, - time: { created: 0, updated: 0 }, - } as Session.Info), - setTitle: () => Effect.void, - }) - + const mockSessionLayer = unseededMockSessionLayer("Renamed Title") const customLayer = KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provideMerge(mockSessionLayer), @@ -848,3 +818,122 @@ it.instance( }, 15_000, ) + +// Helper: build a mock Session.Service layer with list() → empty, get() → session +// with `title`. create() returns a bogus session; setTitle() is a no-op. Used for +// unseeded-session tests where knownTitles has no entry for the session. +function unseededMockSessionLayer(title: string) { + return Layer.mock(Session.Service, { + list: () => Effect.succeed([]), + get: (sid: SessionID) => + Effect.succeed({ + id: sid, + title, + slug: "slug-unseeded", + projectID: ProjectV2.ID.make("proj-unseeded"), + directory: "/tmp/unseeded-test", + version: "test", + permission: {}, + time: { created: 0, updated: 0 }, + } as Session.Info), + create: () => + Effect.succeed({ + id: SessionID.make("ses_unseeded"), + title: "Default Title", + slug: "slug-unseeded", + projectID: ProjectV2.ID.make("proj-unseeded"), + directory: "/tmp/unseeded-test", + version: "test", + permission: {}, + time: { created: 0, updated: 0 }, + } as Session.Info), + setTitle: () => Effect.void, + }) +} + +it.instance( + "title report: unseeded session consumes rename mark → adopted, no POST", + () => { + const requests: Req[] = [] + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + const title = "Cloud Rename" + const mockSessionLayer = unseededMockSessionLayer(title) + const customLayer = KiloSessions.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(mockSessionLayer), + Layer.provide(TestConfig.layer({})), + ) + + return Effect.gen(function* () { + const instance = yield* TestInstance + const sessions = yield* Session.Service + const kilo = yield* KiloSessions.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(created.id)) + yield* Effect.sleep(50) + requests.length = 0 + + // Mark a rename adoption before the Update so the handler consumes it + // instead of falling through to a generated:false POST. + markRenameAdopted(created.id, title) + + emitUpdated(instance.directory, created.id, title) + yield* holdTitlePosts(requests, 0) + expect(consumeRenameAdoption(created.id, title)).toBe(false) + }).pipe(Effect.provide(customLayer)) + }, + 15_000, +) + +it.instance( + "title report: unseeded session consumes auto-title mark → POST with generated:true", + () => { + const requests: Req[] = [] + installFetch(mockFetch(requests)) + patchEnv({ + KILO_API_KEY: "test-token", + KILO_SESSION_INGEST_URL: "https://ingest.kilosessions.ai", + KILO_AGENT_NOTIFICATION_TIMEOUT_MS: "5000", + }) + reset("test-token") + + const title = "Auto Title" + const mockSessionLayer = unseededMockSessionLayer(title) + const customLayer = KiloSessions.layer.pipe( + Layer.provideMerge(Bus.layer), + Layer.provideMerge(mockSessionLayer), + Layer.provide(TestConfig.layer({})), + ) + + return Effect.gen(function* () { + const instance = yield* TestInstance + const sessions = yield* Session.Service + const kilo = yield* KiloSessions.Service + yield* kilo.init() + + const created = yield* sessions.create({}) + yield* Effect.promise(() => KiloSessions.bootstrap(created.id)) + yield* Effect.sleep(50) + requests.length = 0 + + // Mark an auto-title before the Update so the handler consumes it and + // reports generated:true. + markAutoTitle(created.id, title) + + emitUpdated(instance.directory, created.id, title) + const posts = yield* waitTitlePosts(requests, 1, "auto-title POST never fired for unseeded session") + expect(posts[posts.length - 1].body).toEqual({ title, generated: true }) + expect(consumeAutoTitle(created.id, title)).toBe(false) + }).pipe(Effect.provide(customLayer)) + }, + 15_000, +) From 3b0a65ff7e3924e08757634e7eadff5b0721ddee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 19:08:25 +0200 Subject: [PATCH 14/18] test(kilo-sessions): unique ids for unseeded title tests Thread a distinct session id through unseededMockSessionLayer so session_share Storage records do not couple the three unseeded cases. --- .../kilocode/sessions/kilo-sessions-title.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts index 2eed26ee0c3d..d5c407b1abaa 100644 --- a/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts +++ b/packages/opencode/test/kilocode/sessions/kilo-sessions-title.test.ts @@ -789,7 +789,7 @@ it.instance( }) reset("test-token") - const mockSessionLayer = unseededMockSessionLayer("Renamed Title") + const mockSessionLayer = unseededMockSessionLayer("Renamed Title", "ses_unseeded_rename") const customLayer = KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provideMerge(mockSessionLayer), @@ -820,9 +820,9 @@ it.instance( ) // Helper: build a mock Session.Service layer with list() → empty, get() → session -// with `title`. create() returns a bogus session; setTitle() is a no-op. Used for -// unseeded-session tests where knownTitles has no entry for the session. -function unseededMockSessionLayer(title: string) { +// with `title`. create() returns a session with a unique `id` per test so +// Storage session_share records do not couple tests. setTitle() is a no-op. +function unseededMockSessionLayer(title: string, id: string) { return Layer.mock(Session.Service, { list: () => Effect.succeed([]), get: (sid: SessionID) => @@ -838,7 +838,7 @@ function unseededMockSessionLayer(title: string) { } as Session.Info), create: () => Effect.succeed({ - id: SessionID.make("ses_unseeded"), + id: SessionID.make(id), title: "Default Title", slug: "slug-unseeded", projectID: ProjectV2.ID.make("proj-unseeded"), @@ -864,7 +864,7 @@ it.instance( reset("test-token") const title = "Cloud Rename" - const mockSessionLayer = unseededMockSessionLayer(title) + const mockSessionLayer = unseededMockSessionLayer(title, "ses_unseeded_adopt") const customLayer = KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provideMerge(mockSessionLayer), @@ -907,7 +907,7 @@ it.instance( reset("test-token") const title = "Auto Title" - const mockSessionLayer = unseededMockSessionLayer(title) + const mockSessionLayer = unseededMockSessionLayer(title, "ses_unseeded_auto") const customLayer = KiloSessions.layer.pipe( Layer.provideMerge(Bus.layer), Layer.provideMerge(mockSessionLayer), From dccd6946a5eedf272aef911c61e2aef52125ff7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 19:27:28 +0200 Subject: [PATCH 15/18] test(prompt-queue): harden cancel test against interrupt flake Poll hasFollowup instead of sleeping, and settle cancelled prompt promises so Effect interrupt does not leak between tests. --- .../test/kilocode/session-prompt-queue.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index d6a617880210..49fba1080ba4 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -778,12 +778,21 @@ describe("session prompt queue", () => { }), ) - // Let msg2/msg3's enqueue capture the current version before cancel bumps it. - await Bun.sleep(20) + // Wait until follow-ups are actually queued behind the in-flight turn + // (not a fixed sleep) so cancel proves the queued-drop path. + await Effect.runPromise( + pollWithTimeout( + Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? (true as const) : undefined)), + "follow-up prompts never queued behind the in-flight turn", + "3 seconds", + ), + ) expect(calls).toHaveLength(1) await Effect.runPromise(prompt.cancel(session.id)) - await Promise.all([first, second, third]) + // Cancel interrupts the in-flight Effect fibers; settle so interrupt + // does not surface as an unhandled rejection between tests. + await Promise.allSettled([first, second, third]) // The queued prompts must never reach the LLM once cancel flushes the queue. expect(calls).toHaveLength(1) From c3f785bf6f55544db1ce70604ba27a284d73ce53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 19:35:06 +0200 Subject: [PATCH 16/18] test(prompt-queue): wait for both queued follow-ups before cancel Poll snapshot length >= 2 instead of hasFollowup, and assert rejected prompt outcomes are interrupt-shaped after allSettled. --- .../kilocode/session-prompt-queue.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 49fba1080ba4..b8efcedf5a17 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -778,21 +778,27 @@ describe("session prompt queue", () => { }), ) - // Wait until follow-ups are actually queued behind the in-flight turn - // (not a fixed sleep) so cancel proves the queued-drop path. + // Wait until both follow-ups are on the waiting list (hasFollowup alone + // flips true when only the second is queued). await Effect.runPromise( pollWithTimeout( - Effect.sync(() => (KiloSessionPromptQueue.hasFollowup(session.id) ? (true as const) : undefined)), - "follow-up prompts never queued behind the in-flight turn", + Effect.sync(() => + KiloSessionPromptQueue.snapshot(session.id).length >= 2 ? (true as const) : undefined, + ), + "both follow-up prompts never queued behind the in-flight turn", "3 seconds", ), ) expect(calls).toHaveLength(1) await Effect.runPromise(prompt.cancel(session.id)) - // Cancel interrupts the in-flight Effect fibers; settle so interrupt - // does not surface as an unhandled rejection between tests. - await Promise.allSettled([first, second, third]) + // Cancel interrupts in-flight Effect fibers; settle so interrupt does + // not leak as an unhandled rejection, but still require rejects to be + // interrupt-shaped (not an unrelated provider/session failure). + const settled = await Promise.allSettled([first, second, third]) + for (const r of settled) { + if (r.status === "rejected") expect(String(r.reason)).toMatch(/interrupt/i) + } // The queued prompts must never reach the LLM once cancel flushes the queue. expect(calls).toHaveLength(1) From 284d7e85ca2c26fc0e296c648585ff98adec634c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 19:58:10 +0200 Subject: [PATCH 17/18] test(config-overlay): drop tight 15s timeout on permission refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI hit the per-test 15s cap while sibling overlay tests need 17–21s there; fall back to the file's 30s default. --- packages/opencode/test/kilocode/server/config-overlay.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index 28cf2ae86c53..c67598ba435f 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -653,7 +653,6 @@ describe("config overlay routes", () => { overridden: true, }) }, - 15_000, ) test.serial("refreshes agent permissions after global permission update", async () => { From 94a04fdd7aa73ef0f21a10842fbc7ef35956ad01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 20:13:43 +0200 Subject: [PATCH 18/18] test(httpapi-session): raise deletion-race timeout for windows CI Active-prompt delete race was timing out at 10s on windows unit shard 1/4. --- packages/opencode/test/server/httpapi-session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 14bafce50b2f..7306b983e1e5 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -1062,7 +1062,7 @@ describe("session HttpApi", () => { Effect.provide(CrossSpawnSpawner.defaultLayer), ) }, - 10_000, + 30_000, // kilocode_change - windows CI needs headroom beyond 10s ) // kilocode_change end