feat(opencode): remote create_session fields, org metadata, rename/title sync, cancel proof (#12704)

* 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).

* test(opencode): prove cancel→reprompt reaches idle; lock exit survivor

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.

* 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.

* 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.

* 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.

* 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.

* 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.

* style(kilo-sessions): prettier title Updated handler

* fix(kilo-sessions): preserve newer title state

* refactor(kilo-sessions): simplify title reporting tests

* fix(kilo-sessions): report unseeded title updates

* fix(kilo-sessions): consume unseeded title marks

* test(kilo-sessions): cover unseeded title marks

* 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.
This commit is contained in:
Igor Šćekić
2026-07-31 20:25:03 +02:00
committed by GitHub
parent 3404676c10
commit ace509dc60
16 changed files with 2585 additions and 40 deletions
+5
View File
@@ -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.
@@ -29,6 +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,
markAutoTitle,
markRenameAdopted,
} from "@/kilo-sessions/rename-adoptions"
import { SessionStatus } from "@/session/status"
import { Telemetry } from "@kilocode/kilo-telemetry"
import { Question } from "@/question"
@@ -60,6 +67,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<Service, Interface>()("@kilocode/KiloSessions") {}
@@ -81,6 +93,18 @@ export namespace KiloSessions {
const ttlMs = 10_000
/**
* Classify an `http_<status>` 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
@@ -315,6 +339,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
@@ -332,18 +365,102 @@ 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<string, string>()
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) => {
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) },
{ type: "session", data: transport(session) },
])
// Consume marks before the network hop so the 60s TTL does not span
// 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
// 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: "adopted" }
| { kind: "report"; generated: boolean } => {
if (sameTitle) return { kind: "same" }
// 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) }
})()
const restoreTitleState = () => {
// 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)
}
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) {
restoreTitleState()
log.error("session updated ingest failed", { sessionID, error })
return
}
if (outcome.kind === "same") consumeRenameAdoption(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")
const reported = await AppRuntime.runPromise(
reportSessionTitle(sessionID, session.title, { generated: outcome.generated }),
)
if (!reported.ok) {
// 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) {
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,
})
}
}
})
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 }])
@@ -471,7 +588,7 @@ export namespace KiloSessions {
)
})
return Service.of({ init, sendAgentNotification })
return Service.of({ init, sendAgentNotification, reportSessionTitle })
}),
)
@@ -487,6 +604,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 +1149,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
@@ -1090,7 +1253,7 @@ export namespace KiloSessions {
await ingest.sync(sessionId, [
{
type: "kilo_meta",
data: await meta(sessionId),
data: await meta(sessionId, session),
},
{
type: "session",
@@ -1160,10 +1323,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 +1338,16 @@ export namespace KiloSessions {
}
}
async function getOrgId(): Promise<Uuid | undefined> {
/** 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)
}
async function getOrgId(sessionId?: string, info?: Session.Info | null): Promise<Uuid | undefined> {
// 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 +1358,22 @@ export namespace KiloSessions {
})
}
async function resolveSessionOrg(sessionId?: string, info?: Session.Info | null): Promise<Uuid | undefined> {
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
@@ -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"
@@ -48,12 +49,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)
@@ -126,15 +150,15 @@ export namespace RemoteSender {
readonly get: (sessionID: SessionID) => Promise<Session.Info>
readonly children: (sessionID: SessionID) => Promise<Session.Info[]>
// 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<Session.Info>` shape.
// Production falls back to Session.Service.create with `{}`.
readonly create?: (input?: Record<string, never>) => Promise<Session.Info>
// Production forwards {agent, model, metadata} to Session.Service.create.
readonly create?: (input?: CreateSessionInput) => Promise<Session.Info>
// 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<void>
// kilocode_change - injectable setTitle for system session.renamed handling
readonly setTitle?: (input: { sessionID: SessionID; title: string }) => Promise<void>
// kilocode_change end
}
// kilocode_change - K1 W1: in-process attach/detach/ownership/cancel
@@ -266,11 +290,15 @@ export namespace RemoteSender {
// as a wiring bug (a missing seam is never a runtime fallback).
const sessionCreate =
session.create ??
(async (input?: Record<string, never>) => {
(async (input?: CreateSessionInput) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(
Session.Service.use((svc) => svc.create(input as Parameters<typeof svc.create>[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 ??
@@ -767,18 +795,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({
@@ -797,6 +819,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 {
@@ -812,7 +847,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
@@ -1071,6 +1106,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
}
@@ -0,0 +1,66 @@
// 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.
/** 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<string, Entry>()
const autos = new Map<string, Entry>()
function prune(map: Map<string, Entry>, now: number) {
for (const [id, entry] of map) {
if (now - entry.time > MARK_TTL_MS) map.delete(id)
}
}
function mark(map: Map<string, Entry>, sessionId: string, title: string) {
const now = Date.now()
prune(map, now)
map.set(sessionId, { title, time: now })
}
function consume(map: Map<string, Entry>, 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) {
mark(renames, sessionId, title)
}
/** Consume a pending rename adoption when the title matches. */
export function consumeRenameAdoption(sessionId: string, title: string): boolean {
return consume(renames, sessionId, title)
}
export function markAutoTitle(sessionId: string, title: string) {
mark(autos, sessionId, title)
}
/** Consume a pending auto-title mark when the title matches. */
export function consumeAutoTitle(sessionId: string, title: string): boolean {
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)
}
/** Drop every mark (tests). */
export function clearAll() {
renames.clear()
autos.clear()
}
@@ -27,6 +27,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"]
@@ -72,6 +73,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()
}
+18 -3
View File
@@ -317,9 +317,24 @@ 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 - auto-title mark/re-check owned by KiloSessionPrompt
const fresh = yield* sessions.get(input.session.id).pipe(Effect.orElseSucceed(() => null))
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) => {
KiloSessionPrompt.clearAutoTitleMark(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: {
@@ -653,7 +653,6 @@ describe("config overlay routes", () => {
overridden: true,
})
},
15_000,
)
test.serial("refreshes agent permissions after global permission update", async () => {
@@ -778,12 +778,27 @@ describe("session prompt queue", () => {
}),
)
// Let msg2/msg3's enqueue capture the current version before cancel bumps it.
await Bun.sleep(20)
// 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.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))
await Promise.all([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)
@@ -0,0 +1,471 @@
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 { 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"
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<void> | 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<LLM.Interface["stream"]>[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) {
// 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)
}),
} 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<ConfigV1.Info> {
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<ConfigV1.Info>) {
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 = []
clearRenameMarks()
}
/** Match prompt.test.ts: turn a Deferred into a thenable for TestLLMServer.hold. */
const deferredAsPromise = <A>(deferred: Deferred.Deferred<A>): PromiseLike<A> => ({
then: (onfulfilled, onrejected) => {
Effect.runFork(
Deferred.await(deferred).pipe(
Effect.match({
onFailure: (error) => {
onrejected?.(error)
},
onSuccess: (value) => {
onfulfilled?.(value)
},
}),
),
)
return deferredAsPromise(deferred) as PromiseLike<never>
},
})
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<void>()
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<void>()
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)
// 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)
}
}),
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<void>()
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,
)
@@ -0,0 +1,939 @@
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 {
clearAll as clearRenameMarks,
consumeAutoTitle,
consumeRenameAdoption,
markAutoTitle,
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"
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<Config.Interface> = {}) {
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"
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<string, string | undefined>()
let fetchSpy: ReturnType<typeof spyOn> | 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 }
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
}
/** 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<string, number>, 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,
payload: {
id: `evt-${Date.now()}-${Math.random()}`,
type: Session.Event.Updated.type,
properties: { sessionID, info: { title } },
},
})
}
function patchEnv(values: Record<string, string | undefined>) {
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 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 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,
})
// 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()
// 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)
// 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)
// 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()))
})
it.instance("meta falls through invalid metadata orgId to env", () => {
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 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* drainIngest
expect(metaItems(requests).some((m) => m.orgId === ORG_ENV)).toBe(true)
}).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", () => {
// 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* () {
const result = yield* Effect.promise(() => KiloSessions._metaForTests("ses_missing_for_meta_get_fail"))
expect(result.orgId).toBe(ORG_ENV)
}).pipe(Effect.provide(layer()))
})
it.instance("title broadcast: auto-title posts generated true; custom posts generated false; adoption skips", () => {
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")
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 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* 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 })
{
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`)
}
expect(consumeAutoTitle(id, auto)).toBe(false)
requests.length = 0
yield* sessions.setTitle({ sessionID: id, title: "Custom A" })
{
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" })
{
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* holdTitlePosts(requests, 0)
expect(consumeRenameAdoption(id, "From cloud")).toBe(false)
}).pipe(Effect.provide(layer()))
})
it.instance(
"title broadcast: same-title Updated consumes rename adoption (double session.renamed)",
() => {
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")
return Effect.gen(function* () {
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
markRenameAdopted(id, "From cloud")
yield* sessions.setTitle({ sessionID: id, title: "From cloud" })
yield* holdTitlePosts(requests, 0)
expect(consumeRenameAdoption(id, "From cloud")).toBe(false)
markRenameAdopted(id, "From cloud")
yield* sessions.setTitle({ sessionID: id, title: "From cloud" })
yield* holdTitlePosts(requests, 0)
expect(consumeRenameAdoption(id, "From cloud")).toBe(false)
requests.length = 0
yield* sessions.setTitle({ sessionID: id, title: "Local rename away" })
yield* waitTitlePosts(requests, 1, "local rename away never POSTed")
yield* sessions.setTitle({ sessionID: id, title: "From cloud" })
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.provide(layer()))
},
15_000,
)
it.instance("title broadcast: first rename after create (rename-before-prompt) POSTs", () => {
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")
return Effect.gen(function* () {
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
yield* sessions.setTitle({ sessionID: id, title: "Renamed before prompt" })
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.provide(layer()))
})
it.instance("title broadcast: first rename after restart seeds from list and POSTs", () => {
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")
return Effect.gen(function* () {
const sessions = yield* Session.Service
const existing = yield* sessions.create({})
const id = existing.id
const priorTitle = existing.title
const kilo = yield* KiloSessions.Service
yield* kilo.init()
yield* Effect.promise(() => KiloSessions.bootstrap(id))
yield* Effect.sleep(50)
requests.length = 0
yield* sessions.setTitle({ sessionID: id, title: "First rename after restart" })
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 })
expect(priorTitle).not.toBe("First rename after restart")
}).pipe(Effect.provide(layer()))
})
it.instance("reportSessionTitle goes through readiness and tolerates POST failure", () => {
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 },
)
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 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.provide(layer()))
})
it.instance("reportSessionTitle reports not_connected when unauthenticated", () => {
patchEnv({ KILO_API_KEY: undefined })
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.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<string, number>()
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()
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<string, number>()
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()
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<string, number>()
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()
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<string, number>()
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<void>((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,
)
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")
const mockSessionLayer = unseededMockSessionLayer("Renamed Title", "ses_unseeded_rename")
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,
)
// Helper: build a mock Session.Service layer with list() → empty, get() → session
// 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) =>
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(id),
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, "ses_unseeded_adopt")
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, "ses_unseeded_auto")
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,
)
@@ -3585,5 +3585,427 @@ describe("RemoteSender slash commands", () => {
await Promise.resolve()
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<SessionID>([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[] = []
const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(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 <R>(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
// (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"
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 useSpy = spyOn(Session.Service, "use").mockImplementation(((fn: (svc: Session.Interface) => unknown) =>
fn({
create: (input?: Parameters<Session.Interface["create"]>[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 <R>(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()
}
})
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 { clear, consumeRenameAdoption } = await import("../../../src/kilo-sessions/rename-adoptions")
clear(sid)
const sender = RemoteSender.create({
conn,
directory: "/tmp",
log: { ...nolog, warn: (...args: unknown[]) => warnings.push(args) },
subscribe: fakeBus().subscribe,
provide: async <R>(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 <R>(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 { clear, consumeRenameAdoption, markRenameAdopted } = await import(
"../../../src/kilo-sessions/rename-adoptions"
)
clear(sid)
let sawMarkInsideSetTitle = false
const sender = RemoteSender.create({
conn,
directory: "/tmp",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(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 () => {
// 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")
},
},
})
sender.handle({
type: "system",
event: "session.renamed",
data: { sessionId: sid, title: "Cloud title" },
})
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
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)
})
})
// kilocode_change end
@@ -0,0 +1,74 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import {
MARK_TTL_MS,
clear,
clearAll,
consumeAutoTitle,
consumeRenameAdoption,
markAutoTitle,
markRenameAdopted,
} 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)
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)
})
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
}
})
})
@@ -471,6 +471,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(
@@ -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)
@@ -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
@@ -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<void>()
const finished = yield* Deferred.make<void>()
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",
() =>