mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user