mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into docs/custom-provider-options
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show the docs URL in an alert dialog when the browser cannot be opened on headless systems instead of silently failing.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep chat output updating after reverting and resubmitting a prompt.
|
||||
@@ -172,6 +172,16 @@ const SESSION_SCOPED_PART_EVENTS = new Set(["message.part.updated", "message.par
|
||||
const isSessionScopedPartEvent = (type: string) => SESSION_SCOPED_PART_EVENTS.has(type)
|
||||
|
||||
type SyncPayload = Extract<GlobalEvent["payload"], { type: "sync" }>
|
||||
type RawSyncPayload = {
|
||||
type: "sync"
|
||||
syncEvent: {
|
||||
type: SyncPayload["name"]
|
||||
id: string
|
||||
seq: number
|
||||
aggregateID: string
|
||||
data: unknown
|
||||
}
|
||||
}
|
||||
type LegacySyncEvent =
|
||||
| {
|
||||
id: string
|
||||
@@ -201,6 +211,7 @@ type LegacySyncEvent =
|
||||
| {
|
||||
source: "sync"
|
||||
id: string
|
||||
seq: number
|
||||
type: "session.updated"
|
||||
properties: Extract<SyncPayload, { name: "session.updated.1" }>["data"]
|
||||
}
|
||||
@@ -234,24 +245,35 @@ function isFullSessionUpdatedEvent(event: ProviderEvent): event is FullSessionUp
|
||||
return event.type === "session.updated" && !isLegacySyncEvent(event)
|
||||
}
|
||||
|
||||
function unwrapSyncEvent(event: GlobalEvent["payload"]): ProviderEvent | undefined {
|
||||
export function unwrapSyncEvent(event: GlobalEvent["payload"] | RawSyncPayload): ProviderEvent | undefined {
|
||||
if (event.type !== "sync") return event
|
||||
const payload =
|
||||
"syncEvent" in event
|
||||
? ({
|
||||
type: "sync",
|
||||
name: event.syncEvent.type,
|
||||
id: event.syncEvent.id,
|
||||
seq: event.syncEvent.seq,
|
||||
aggregateID: event.syncEvent.aggregateID,
|
||||
data: event.syncEvent.data,
|
||||
} as SyncPayload)
|
||||
: event
|
||||
|
||||
switch (event.name) {
|
||||
switch (payload.name) {
|
||||
case "message.updated.1":
|
||||
return { id: event.id, type: "message.updated", properties: event.data }
|
||||
return { id: payload.id, type: "message.updated", properties: payload.data }
|
||||
case "message.removed.1":
|
||||
return { id: event.id, type: "message.removed", properties: event.data }
|
||||
return { id: payload.id, type: "message.removed", properties: payload.data }
|
||||
case "message.part.updated.1":
|
||||
return { id: event.id, type: "message.part.updated", properties: event.data }
|
||||
return { id: payload.id, type: "message.part.updated", properties: payload.data }
|
||||
case "message.part.removed.1":
|
||||
return { id: event.id, type: "message.part.removed", properties: event.data }
|
||||
return { id: payload.id, type: "message.part.removed", properties: payload.data }
|
||||
case "session.created.1":
|
||||
return { id: event.id, type: "session.created", properties: event.data }
|
||||
return { id: payload.id, type: "session.created", properties: payload.data }
|
||||
case "session.updated.1":
|
||||
return { source: "sync", id: event.id, type: "session.updated", properties: event.data }
|
||||
return { source: "sync", id: payload.id, seq: payload.seq, type: "session.updated", properties: payload.data }
|
||||
case "session.deleted.1":
|
||||
return { id: event.id, type: "session.deleted", properties: event.data }
|
||||
return { id: payload.id, type: "session.deleted", properties: payload.data }
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -308,6 +330,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private promptRecovery: Promise<void> | null = null
|
||||
private trackedSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = new Set()
|
||||
private readonly checkpoints = new Map<string, Promise<void>>()
|
||||
private readonly revisions = new Map<string, { id: string; seq: number }>()
|
||||
private readonly refreshes = new Map<string, number>()
|
||||
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
|
||||
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
|
||||
private readonly aborts = new SessionAbort()
|
||||
@@ -389,10 +414,27 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
private setCurrentSession(session: Session | null): void {
|
||||
const ids = new Set([this.currentSession?.id, session?.id])
|
||||
for (const id of ids) {
|
||||
if (id) this.refreshes.set(id, (this.refreshes.get(id) ?? 0) + 1)
|
||||
}
|
||||
this.currentSession = session
|
||||
this.opts.tabTitle?.(nativeTitle(session))
|
||||
}
|
||||
|
||||
private checkpoint(sid: string, run: () => Promise<void>): void {
|
||||
const prior = this.checkpoints.get(sid) ?? Promise.resolve()
|
||||
const pending = prior.catch(() => undefined).then(run)
|
||||
const cleanup = () => {
|
||||
if (this.checkpoints.get(sid) === pending) this.checkpoints.delete(sid)
|
||||
}
|
||||
this.checkpoints.set(sid, pending)
|
||||
void pending.then(cleanup, (error) => {
|
||||
console.error("[Kilo New] checkpoint mutation failed:", error)
|
||||
cleanup()
|
||||
})
|
||||
}
|
||||
|
||||
private stopCurrentSessionProcesses(next?: string): void {
|
||||
const sid = this.contextSessionID ?? this.currentSession?.id
|
||||
if (!sid || sid === next) return
|
||||
@@ -546,6 +588,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
data: profileData,
|
||||
})
|
||||
|
||||
if (this.currentSession) {
|
||||
this.refreshSessionDetails(this.currentSession.id, this.getWorkspaceDirectory(this.currentSession.id))
|
||||
}
|
||||
|
||||
// Re-send cached worktree stats and git status after webview reload.
|
||||
if (this.cachedStats) this.postMessage(this.cachedStats)
|
||||
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
|
||||
@@ -853,14 +899,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.handleAbort(message.sessionID)
|
||||
break
|
||||
case "revertSession":
|
||||
this.handleRevertSession(message.sessionID, message.messageID, message.partID).catch((e) =>
|
||||
console.error("[Kilo New] handleRevertSession failed:", e),
|
||||
this.checkpoint(message.sessionID, () =>
|
||||
this.handleRevertSession(message.sessionID, message.messageID, message.partID),
|
||||
)
|
||||
break
|
||||
case "unrevertSession":
|
||||
this.handleUnrevertSession(message.sessionID).catch((e) =>
|
||||
console.error("[Kilo New] handleUnrevertSession failed:", e),
|
||||
)
|
||||
this.checkpoint(message.sessionID, () => this.handleUnrevertSession(message.sessionID))
|
||||
break
|
||||
case "permissionResponse":
|
||||
await handlePermissionResponse(
|
||||
@@ -1506,13 +1550,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
/** Non-blocking: refresh session metadata + status for the webview after switching. */
|
||||
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
|
||||
if (!this.client) return
|
||||
const revision = this.revisions.get(sessionID)
|
||||
const refresh = (this.refreshes.get(sessionID) ?? 0) + 1
|
||||
this.refreshes.set(sessionID, refresh)
|
||||
this.client.session
|
||||
.get({ sessionID, directory: dir })
|
||||
.then((r) => {
|
||||
if (r.data && !signal?.aborted && this.contextSessionID === sessionID) {
|
||||
this.setCurrentSession(r.data)
|
||||
this.contextSessionID = r.data.id
|
||||
if (!r.data || signal?.aborted || this.contextSessionID !== sessionID) return
|
||||
if (this.refreshes.get(sessionID) !== refresh) {
|
||||
if (this.revisions.get(sessionID) !== revision) this.refreshSessionDetails(sessionID, dir, signal)
|
||||
return
|
||||
}
|
||||
if (this.revisions.get(sessionID) !== revision) {
|
||||
this.refreshSessionDetails(sessionID, dir, signal)
|
||||
return
|
||||
}
|
||||
this.setCurrentSession(r.data)
|
||||
this.contextSessionID = r.data.id
|
||||
this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(r.data) })
|
||||
})
|
||||
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
|
||||
this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
|
||||
@@ -1760,6 +1815,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.aborts.delete(sessionID)
|
||||
this.lastReconciledAt.delete(sessionID)
|
||||
this.checkpoints.delete(sessionID)
|
||||
this.revisions.delete(sessionID)
|
||||
this.refreshes.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.contextSessionID = undefined
|
||||
@@ -2646,6 +2704,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.connectionService.recordMessageSessionId(messageID, sid)
|
||||
}
|
||||
|
||||
await this.checkpoints.get(sid)
|
||||
await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Message request", () =>
|
||||
this.withRetry(
|
||||
() =>
|
||||
@@ -2724,6 +2783,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
const sid = resolved!.sid
|
||||
const dir = resolved!.dir
|
||||
await this.checkpoints.get(sid)
|
||||
await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () =>
|
||||
this.withRetry(
|
||||
() =>
|
||||
@@ -2772,9 +2832,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to revert session:", error)
|
||||
this.postMessage({ type: "error", message: "Failed to revert session", sessionID })
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
|
||||
if (!data) throw new Error("Revert returned no session")
|
||||
this.refreshes.set(sessionID, (this.refreshes.get(sessionID) ?? 0) + 1)
|
||||
if (this.currentSession?.id === sessionID) this.setCurrentSession(data)
|
||||
this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
|
||||
}
|
||||
|
||||
private async handleUnrevertSession(sessionID: string): Promise<void> {
|
||||
@@ -2784,9 +2847,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to unrevert session:", error)
|
||||
this.postMessage({ type: "error", message: "Failed to redo session", sessionID })
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
|
||||
if (!data) throw new Error("Redo returned no session")
|
||||
this.refreshes.set(sessionID, (this.refreshes.get(sessionID) ?? 0) + 1)
|
||||
if (this.currentSession?.id === sessionID) this.setCurrentSession(data)
|
||||
this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3171,6 +3237,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.updated") {
|
||||
// Full bus snapshots duplicate sync patches with the same event ID but no sequence metadata.
|
||||
if (isFullSessionUpdatedEvent(event)) return
|
||||
const sid = event.properties.sessionID
|
||||
const revision = this.revisions.get(sid)
|
||||
const versioned = event.seq > 0 || (revision?.seq ?? 0) > 0
|
||||
if (revision && (versioned ? event.seq <= revision.seq : event.id <= revision.id)) return
|
||||
this.revisions.set(sid, { id: event.id, seq: event.seq })
|
||||
}
|
||||
|
||||
// Refresh provider and agent lists when the server signals a state disposal
|
||||
if (event.type === "global.disposed") {
|
||||
void this.reloadAfterAuthChange()
|
||||
@@ -3202,10 +3278,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.trackedSessionIds.add(event.properties.info.id)
|
||||
}
|
||||
if (event.type === "session.updated" && this.currentSession?.id === event.properties.sessionID) {
|
||||
const session = isLegacySyncEvent(event)
|
||||
? applySessionPatch(this.currentSession, event.properties.info)
|
||||
: event.properties.info
|
||||
this.setCurrentSession(session)
|
||||
this.setCurrentSession(applySessionPatch(this.currentSession, event.properties.info))
|
||||
this.contextSessionID = event.properties.sessionID
|
||||
}
|
||||
|
||||
@@ -3248,9 +3321,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
const msg = isLegacySyncEvent(event)
|
||||
? this.mapSyncEventToWebviewMessage(event)
|
||||
: isFullSessionUpdatedEvent(event)
|
||||
? { type: "sessionUpdated" as const, session: this.sessionToWebview(event.properties.info) }
|
||||
: mapSSEEventToWebviewMessage(event, sessionID)
|
||||
: mapSSEEventToWebviewMessage(event, sessionID)
|
||||
if (!msg) return
|
||||
if (msg.type === "partUpdated") {
|
||||
this.streams.push({ ...msg, part: this.slimPart(msg.part) })
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, spyOn } from "bun:test"
|
||||
import type { PartUpdate } from "../../src/shared/stream-messages"
|
||||
|
||||
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
|
||||
const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
const { KiloProvider, unwrapSyncEvent } = await import("../../src/KiloProvider")
|
||||
|
||||
type State = "connecting" | "connected" | "disconnected" | "error"
|
||||
|
||||
@@ -34,6 +34,21 @@ function mkMessage(id: string, role: "user" | "assistant", time = 0) {
|
||||
}
|
||||
}
|
||||
|
||||
function mkSession(revert?: { messageID: string }) {
|
||||
return {
|
||||
id: "s1",
|
||||
slug: "session",
|
||||
version: "1",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
revert,
|
||||
}
|
||||
}
|
||||
|
||||
function mkResult(items: unknown[]) {
|
||||
return { data: items, response: { headers: new Headers() } }
|
||||
}
|
||||
@@ -42,6 +57,7 @@ function createClient(options?: {
|
||||
messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
|
||||
messagesData?: unknown[]
|
||||
deleteDeferred?: Deferred<unknown>
|
||||
revertDeferred?: Deferred<{ data?: unknown; error?: unknown }>
|
||||
sessionData?: unknown
|
||||
sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }>
|
||||
abortFailures?: string[]
|
||||
@@ -49,10 +65,14 @@ function createClient(options?: {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
const stopped: { sessionID: string; directory?: string }[] = []
|
||||
const aborted: { sessionID: string; directory?: string }[] = []
|
||||
const prompted: Array<Record<string, unknown>> = []
|
||||
const reverted: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
calls,
|
||||
stopped,
|
||||
aborted,
|
||||
prompted,
|
||||
reverted,
|
||||
session: {
|
||||
list: async () => ({ data: [] }),
|
||||
get: async (params: { sessionID: string; directory?: string }) => {
|
||||
@@ -60,6 +80,15 @@ function createClient(options?: {
|
||||
return { data: options?.sessionData ?? null }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
revert: async (params: Record<string, unknown>) => {
|
||||
reverted.push(params)
|
||||
if (options?.revertDeferred) return options.revertDeferred.promise
|
||||
return { data: mkSession({ messageID: String(params.messageID) }) }
|
||||
},
|
||||
promptAsync: async (params: Record<string, unknown>) => {
|
||||
prompted.push(params)
|
||||
return { data: undefined }
|
||||
},
|
||||
abort: async (params: { sessionID: string; directory?: string }) => {
|
||||
aborted.push(params)
|
||||
if (params.directory && options?.abortFailures?.includes(params.directory)) throw new Error("abort failed")
|
||||
@@ -120,14 +149,21 @@ function createConnection(client: ReturnType<typeof createClient>) {
|
||||
type ProviderInternals = {
|
||||
connectionState: State
|
||||
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
|
||||
currentSession: { id: string; directory?: string } | null
|
||||
currentSession: { id: string; directory?: string; revert?: { messageID: string } } | null
|
||||
contextSessionID: string | undefined
|
||||
sessionDirectories: Map<string, string>
|
||||
trackedSessionIds: Set<string>
|
||||
checkpoints: Map<string, Promise<void>>
|
||||
revisions: Map<string, { id: string; seq: number }>
|
||||
streams: { push: (msg: PartUpdate) => void }
|
||||
checkpoint: (sid: string, run: () => Promise<void>) => void
|
||||
gatherEditorContext: () => Promise<Record<string, never>>
|
||||
refreshSessionDetails: (sid: string, dir: string) => void
|
||||
stopCurrentSessionProcesses: (next?: string) => void
|
||||
handleEvent: (event: unknown, directory?: string) => void
|
||||
handleAbort: (sid?: string) => Promise<void>
|
||||
handleRevertSession: (sid: string, messageID: string) => Promise<void>
|
||||
handleSendMessage: (text: string, messageID?: string, sessionID?: string) => Promise<void>
|
||||
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
|
||||
handleDeleteSession: (sid: string) => Promise<void>
|
||||
}
|
||||
@@ -210,6 +246,240 @@ describe("KiloProvider.handleAbort", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider revert ordering", () => {
|
||||
it("unwraps the nested sync payload emitted by the live SSE endpoint", () => {
|
||||
const event = unwrapSyncEvent({
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
type: "session.updated.1",
|
||||
id: "evt_clear",
|
||||
seq: 0,
|
||||
aggregateID: "sessionID",
|
||||
data: { sessionID: "s1", info: { revert: null } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(event).toEqual({
|
||||
source: "sync",
|
||||
id: "evt_clear",
|
||||
seq: 0,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { revert: null } },
|
||||
})
|
||||
})
|
||||
|
||||
it("waits for an in-flight revert before submitting the replacement prompt", async () => {
|
||||
const revert = defer<{ data?: unknown; error?: unknown }>()
|
||||
const client = createClient({ revertDeferred: revert })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = mkSession()
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1"))
|
||||
const send = internal.handleSendMessage("replacement", "m2", "s1")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.reverted).toHaveLength(1)
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
|
||||
revert.resolve({ data: mkSession({ messageID: "m1" }) })
|
||||
await send
|
||||
|
||||
expect(client.prompted).toHaveLength(1)
|
||||
expect(client.prompted[0]?.sessionID).toBe("s1")
|
||||
})
|
||||
|
||||
it("waits for a revert queued while the replacement prompt gathers context", async () => {
|
||||
const context = defer<Record<string, never>>()
|
||||
const revert = defer<{ data?: unknown; error?: unknown }>()
|
||||
const client = createClient({ revertDeferred: revert })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = mkSession()
|
||||
internal.gatherEditorContext = () => context.promise
|
||||
|
||||
const send = internal.handleSendMessage("replacement", "m2", "s1")
|
||||
await Promise.resolve()
|
||||
internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1"))
|
||||
context.resolve({})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
|
||||
revert.resolve({ data: mkSession({ messageID: "m1" }) })
|
||||
await send
|
||||
|
||||
expect(client.prompted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not submit the replacement prompt when the revert fails", async () => {
|
||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||
const revert = defer<{ data?: unknown; error?: unknown }>()
|
||||
const client = createClient({ revertDeferred: revert })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.currentSession = mkSession()
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
internal.checkpoint("s1", () => internal.handleRevertSession("s1", "m1"))
|
||||
const send = internal.handleSendMessage("replacement", "m2", "s1")
|
||||
await Promise.resolve()
|
||||
revert.resolve({ error: new Error("revert failed") })
|
||||
await send
|
||||
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: "sendMessageFailed", messageID: "m2" }))
|
||||
error.mockRestore()
|
||||
})
|
||||
|
||||
it("does not restore a stale revert boundary after a newer clear update", () => {
|
||||
const client = createClient()
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.currentSession = mkSession({ messageID: "m1" })
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
internal.handleEvent({
|
||||
source: "sync",
|
||||
id: "evt_000000000002",
|
||||
seq: 0,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { revert: null } },
|
||||
})
|
||||
const count = sent.length
|
||||
|
||||
internal.handleEvent({
|
||||
source: "sync",
|
||||
id: "evt_000000000001",
|
||||
seq: 0,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { revert: { messageID: "m1" } } },
|
||||
})
|
||||
internal.handleEvent({
|
||||
id: "evt_000000000001",
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: mkSession({ messageID: "m1" }) },
|
||||
})
|
||||
|
||||
expect(internal.currentSession?.revert).toBeUndefined()
|
||||
expect(internal.revisions.get("s1")).toEqual({ id: "evt_000000000002", seq: 0 })
|
||||
expect(sent).toHaveLength(count)
|
||||
expect(sent.at(-1)).toMatchObject({ type: "sessionUpdated", session: { id: "s1", revert: null } })
|
||||
})
|
||||
|
||||
it("uses sequence ordering for workspace-replayed session updates", () => {
|
||||
const client = createClient()
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = mkSession({ messageID: "m1" })
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
internal.handleEvent({
|
||||
source: "sync",
|
||||
id: "evt_ffffffffffff",
|
||||
seq: 1,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { revert: { messageID: "m1" } } },
|
||||
})
|
||||
internal.handleEvent({
|
||||
source: "sync",
|
||||
id: "evt_000000000001",
|
||||
seq: 2,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { revert: null } },
|
||||
})
|
||||
|
||||
expect(internal.currentSession?.revert).toBeUndefined()
|
||||
expect(internal.revisions.get("s1")).toEqual({ id: "evt_000000000001", seq: 2 })
|
||||
})
|
||||
|
||||
it("publishes authoritative session state after a missed clear event", async () => {
|
||||
const client = createClient({ sessionData: mkSession() })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.currentSession = mkSession({ messageID: "m1" })
|
||||
internal.contextSessionID = "s1"
|
||||
|
||||
internal.refreshSessionDetails("s1", "/repo")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(internal.currentSession?.revert).toBeUndefined()
|
||||
expect(sent.at(-1)).toMatchObject({ type: "sessionUpdated", session: { id: "s1", revert: null } })
|
||||
})
|
||||
|
||||
it("retries a focused session refresh after a concurrent session update", async () => {
|
||||
const first = defer<{ data: unknown }>()
|
||||
const second = defer<{ data: unknown }>()
|
||||
let calls = 0
|
||||
const client = createClient({
|
||||
sessionGet: async () => {
|
||||
calls += 1
|
||||
return calls === 1 ? first.promise : second.promise
|
||||
},
|
||||
})
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = mkSession({ messageID: "m1" })
|
||||
internal.contextSessionID = "s1"
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
internal.refreshSessionDetails("s1", "/repo")
|
||||
internal.handleEvent({
|
||||
source: "sync",
|
||||
id: "evt_000000000001",
|
||||
seq: 0,
|
||||
type: "session.updated",
|
||||
properties: { sessionID: "s1", info: { title: "updated" } },
|
||||
})
|
||||
first.resolve({ data: mkSession() })
|
||||
await Bun.sleep(0)
|
||||
expect(calls).toBe(2)
|
||||
|
||||
second.resolve({ data: { ...mkSession(), title: "updated" } })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(internal.currentSession?.id).toBe("s1")
|
||||
expect(internal.currentSession?.revert).toBeUndefined()
|
||||
})
|
||||
|
||||
it("ignores an older session refresh that resolves last", async () => {
|
||||
const first = defer<{ data: unknown }>()
|
||||
const second = defer<{ data: unknown }>()
|
||||
let calls = 0
|
||||
const client = createClient({
|
||||
sessionGet: async () => {
|
||||
calls += 1
|
||||
return calls === 1 ? first.promise : second.promise
|
||||
},
|
||||
})
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.currentSession = mkSession({ messageID: "m1" })
|
||||
internal.contextSessionID = "s1"
|
||||
|
||||
internal.refreshSessionDetails("s1", "/repo")
|
||||
internal.refreshSessionDetails("s1", "/repo")
|
||||
second.resolve({ data: mkSession() })
|
||||
await Bun.sleep(0)
|
||||
first.resolve({ data: mkSession({ messageID: "m1" }) })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(internal.currentSession?.revert).toBeUndefined()
|
||||
expect(sent.filter((msg) => (msg as { type?: string }).type === "sessionUpdated")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("ignores a session refresh superseded by a revert response", async () => {
|
||||
const session = defer<{ data: unknown }>()
|
||||
const client = createClient({ sessionGet: async () => session.promise })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.currentSession = mkSession()
|
||||
internal.contextSessionID = "s1"
|
||||
|
||||
internal.refreshSessionDetails("s1", "/repo")
|
||||
await internal.handleRevertSession("s1", "m1")
|
||||
session.resolve({ data: mkSession() })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(internal.currentSession?.revert).toEqual({ messageID: "m1" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
|
||||
it("stops background processes for the previous session when switching sessions", async () => {
|
||||
const client = createClient({
|
||||
|
||||
@@ -40,16 +40,14 @@ describe("revert session synchronization", () => {
|
||||
expect(unrevert).toContain('type: "sessionUpdated"')
|
||||
})
|
||||
|
||||
it("distinguishes partial sync patches from full bus snapshots", () => {
|
||||
it("uses ordered sync patches instead of duplicate bus snapshots", () => {
|
||||
expect(provider).toMatch(/source: "sync"/)
|
||||
expect(provider).toMatch(
|
||||
/if \(event\.type === "session\.updated"\) return "source" in event && event\.source === "sync"/,
|
||||
)
|
||||
expect(provider).toMatch(/if \(isFullSessionUpdatedEvent\(event\)\) return/)
|
||||
expect(provider).toMatch(
|
||||
/isLegacySyncEvent\(event\)\s*\? applySessionPatch\(this\.currentSession, event\.properties\.info\)\s*:\s*event\.properties\.info/,
|
||||
)
|
||||
expect(provider).toMatch(
|
||||
/isFullSessionUpdatedEvent\(event\)\s*\? \{ type: "sessionUpdated" as const, session: this\.sessionToWebview\(event\.properties\.info\) \}/,
|
||||
/this\.setCurrentSession\(applySessionPatch\(this\.currentSession, event\.properties\.info\)\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -703,8 +703,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
name: "docs.open",
|
||||
title: "Open docs",
|
||||
run: () => {
|
||||
open(KiloApp.DOCS_URL).catch(() => {}) // kilocode_change
|
||||
dialog.clear()
|
||||
open(KiloApp.DOCS_URL).catch(() => {
|
||||
// kilocode_change start
|
||||
dialog.replace(() => (
|
||||
<DialogAlert
|
||||
title="Cannot open browser"
|
||||
message={`No display detected.\n\nOpen the docs manually:\n${KiloApp.DOCS_URL}`}
|
||||
/>
|
||||
))
|
||||
// kilocode_change end
|
||||
})
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user