Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { usePlatform } from "@/context/platform"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { promptEnabled, promptProbe } from "@/testing/prompt"
import { sessionDiffIncludes } from "@/context/global-sync/session-diff"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments } from "./prompt-input/attachments"
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
Expand Down Expand Up @@ -173,9 +174,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionID = params.id
if (!sessionID) return false

const diffs = sync.data.session_diff[sessionID]
if (!diffs) return false
return diffs.some((diff) => diff.file === path)
return sessionDiffIncludes(sync.data.session_diff[sessionID], path)
}

const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/context/global-sync/event-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,27 @@ describe("applyDirectoryEvent", () => {
expect(todos).toEqual([dropped.id])
})

test("drops malformed session diffs so they can be re-fetched", () => {
const [store, setStore] = createStore(baseState())

applyDirectoryEvent({
event: {
type: "session.diff",
properties: {
sessionID: "ses_1",
diff: {},
},
},
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})

expect(store.session_diff.ses_1).toBeUndefined()
})

test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
const [store, setStore] = createStore(
baseState({
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/context/global-sync/event-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
import { hasSessionDiffs, sessionDiffs } from "./session-diff"

const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])

Expand Down Expand Up @@ -161,8 +162,13 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
const props = event.properties as { sessionID: string; diff: unknown }
const value = sessionDiffs(props.diff)
if (hasSessionDiffs(props.diff)) {
input.setStore("session_diff", props.sessionID, value)
break
}
input.setStore("session_diff", props.sessionID, undefined)
break
}
case "todo.updated": {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/global-sync/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import type {
export const SESSION_CACHE_LIMIT = 40

type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_status: Record<string, SessionStatus | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/context/global-sync/session-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import {
hasSessionDiffs,
sessionDiffCount,
sessionDiffIncludes,
sessionDiffs,
shouldFetchSessionDiff,
} from "./session-diff"

describe("session diff helpers", () => {
test("normalize unknown values to arrays", () => {
const list = [{ file: "a.ts", before: "", after: "", additions: 1, deletions: 0 }] as FileDiff[]

expect(sessionDiffs(list)).toEqual(list)
expect(sessionDiffs({})).toEqual([])
expect(sessionDiffs([{}])).toEqual([])
expect(sessionDiffs(undefined)).toEqual([])
expect(hasSessionDiffs(list)).toBe(true)
expect(hasSessionDiffs({})).toBe(false)
expect(hasSessionDiffs([{}])).toBe(false)
})

test("model the crash-prone consumer operations safely", () => {
const list = [{ file: "a.ts", before: "", after: "", additions: 1, deletions: 0 }] as FileDiff[]

expect(sessionDiffCount(list)).toBe(1)
expect(sessionDiffCount({})).toBe(0)
expect(sessionDiffIncludes(list, "a.ts")).toBe(true)
expect(sessionDiffIncludes({}, "a.ts")).toBe(false)
})

test("marks malformed cache entries for refetch", () => {
expect(shouldFetchSessionDiff({}, false)).toBe(true)
expect(shouldFetchSessionDiff([{}], false)).toBe(true)
expect(shouldFetchSessionDiff(undefined, false)).toBe(true)
expect(shouldFetchSessionDiff([], false)).toBe(false)
expect(shouldFetchSessionDiff([], true)).toBe(true)
})
})
47 changes: 47 additions & 0 deletions packages/app/src/context/global-sync/session-diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { FileDiff } from "@opencode-ai/sdk/v2/client"

function isSessionDiff(value: unknown): value is FileDiff {
if (!value || typeof value !== "object") return false
const item = value as Record<string, unknown>
if (typeof item.file !== "string") return false
if (typeof item.before !== "string") return false
if (typeof item.after !== "string") return false
if (typeof item.additions !== "number") return false
if (typeof item.deletions !== "number") return false
if (
item.status !== undefined &&
item.status !== "added" &&
item.status !== "deleted" &&
item.status !== "modified"
) {
return false
}
return true
}

function parseSessionDiffs(value: unknown) {
if (!Array.isArray(value)) return
if (!value.every(isSessionDiff)) return
return value
}

export function sessionDiffs(value: unknown): FileDiff[] {
return parseSessionDiffs(value) ?? []
}

export function hasSessionDiffs(value: unknown) {
return parseSessionDiffs(value) !== undefined
}

export function shouldFetchSessionDiff(value: unknown, force?: boolean) {
if (force) return true
return !hasSessionDiffs(value)
}

export function sessionDiffCount(value: unknown) {
return sessionDiffs(value).length
}

export function sessionDiffIncludes(value: unknown, path: string) {
return sessionDiffs(value).some((diff) => diff.file === path)
}
2 changes: 1 addition & 1 deletion packages/app/src/context/global-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: FileDiff[] | undefined
}
todo: {
[sessionID: string]: Todo[]
Expand Down
102 changes: 102 additions & 0 deletions packages/app/src/context/sync-session-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, mock, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import type { State } from "./global-sync/types"
import { createSyncContextValue } from "./sync"

type SyncDeps = Parameters<typeof createSyncContextValue>[0]

function baseState(): State {
return {
status: "complete",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: false,
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "/tmp/project", home: "" },
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: false,
mcp: {},
lsp_ready: false,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
}
}

describe("sync session diff recovery", () => {
test("re-fetches malformed cached diffs until a valid array is stored", async () => {
const sessionID = "ses_1"
const valid = [{ file: "a.ts", before: "", after: "", additions: 1, deletions: 0 }] as FileDiff[]
const [store, setStore] = createStore({
...baseState(),
session_diff: { [sessionID]: {} as State["session_diff"][string] },
})

const diff = mock(async () => {
const count = diff.mock.calls.length
return count === 1 ? ({ data: {} } as const) : ({ data: valid } as const)
})

const globalSync = {
child: () => [store, setStore],
data: {
project: [],
session_todo: {},
},
todo: {
set() {},
},
}

const sdk = {
directory: "/tmp/project",
client: {
session: {
diff,
},
},
}

await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const sync = createSyncContextValue({
globalSync: globalSync as unknown as SyncDeps["globalSync"],
sdk: sdk as unknown as SyncDeps["sdk"],
})
void (async () => {
try {
await sync.session.diff(sessionID)
expect(diff).toHaveBeenCalledTimes(1)
expect(store.session_diff[sessionID]).toBeUndefined()

await sync.session.diff(sessionID)
expect(diff).toHaveBeenCalledTimes(2)
expect(store.session_diff[sessionID]).toEqual(valid)

await sync.session.diff(sessionID)
expect(diff).toHaveBeenCalledTimes(2)
resolve()
} catch (error) {
reject(error)
} finally {
dispose()
}
})()
})
})
})
})
Loading
Loading