fix: complete subagent output and file workspace interactions

OpenCode child cards now show ordered text, reasoning and tool progress,
with the final result displayed separately. Local SDK and remote ACP paths
share child tracking, including progress retained across recovery and cancellation.
Remote live progress requires an Agent package containing the event plugin;
older Agent packages still expose final results without inventing missing history.

Knowledge-library rows remain clickable when native label activation changes
focus. File workspace changes use colored Git status letters and per-file
staged/unstaged diffs, including deleted, renamed and untracked files.
Refreshing reloads the selected diff and expanded directories, and more than
50 changes remain accessible through incremental loading.

Validated with 3552 passing tests, typecheck, lint, desktop build, current-source
Linux Host scenarios and isolated Windows desktop UI interactions.

Release note: 展开 OpenCode 子代理卡片可查看执行过程、工具详情与独立最终结果;知识库支持整行勾选。工作区支持单文件暂存与未暂存差异,刷新会更新当前差异,更多变更可继续加载。远端实时过程需要更新 GoodBuddy Agent。
This commit is contained in:
mesalogo
2026-09-06 22:13:45 +08:00
parent b03c25685c
commit d08ba33c3e
43 changed files with 1118 additions and 141 deletions
+52
View File
@@ -1544,6 +1544,58 @@ describe('AcpRemoteRuntime', () => {
})
})
it('keeps ACP content results and bridged child progress in the same card', async () => {
const client: { value?: AgentSideConnection } = {}
const input = { subagent_type: 'general', prompt: 'Read seed' }
const server = fakeServer({
prompt: async ({ sessionId }) => {
for (const event of [
{ type: 'message.part.updated', properties: {
sessionID: sessionId, part: {
id: 'task', callID: 'task', type: 'tool', tool: 'task',
state: { status: 'running', input, metadata: { sessionId: 'child' } }
}
} },
{ type: 'message.part.delta', properties: {
sessionID: 'child', partID: 'text', field: 'text', delta: 'Inspecting seed'
} },
{ type: 'message.part.updated', properties: {
sessionID: 'child', part: {
id: 'read', callID: 'read', type: 'tool', tool: 'read',
state: { status: 'completed', input: { path: 'seed.txt' }, output: 'seed' }
}
} }
]) {
await client.value!.sessionUpdate({
sessionId, update: {
sessionUpdate: 'tool_call_update', toolCallId: 'task',
_meta: { goodbuddySubagentEvent: event }
}
})
}
await client.value!.sessionUpdate({
sessionId, update: {
sessionUpdate: 'tool_call', toolCallId: 'task', title: 'Read seed', rawInput: input,
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: '**Final seed**' } }]
}
})
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const events = await collect(runtime(server).run(request, new AbortController().signal))
const children = events.filter(event => event.type === 'subagent')
expect(children.at(-1)).toMatchObject({
state: 'completed', output: '**Final seed**',
progress: [
{ type: 'text', content: 'Inspecting seed' },
{ type: 'tool', tool: { name: 'read', state: 'completed', output: 'seed' } }
]
})
expect(events.some(event => event.type === 'text')).toBe(false)
})
it('bounds native subagent output per event without a request-wide stop', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
+43 -5
View File
@@ -60,6 +60,7 @@ import {
} from './remote-runtime-channel'
import type { RuntimeSessionBindingStore } from './runtime-session-binding-store'
import {
OpenCodeSubagentProgress,
parseOpenCodeSubagentInput,
toOpenCodeSubagentEvent
} from './opencode-subagent'
@@ -136,6 +137,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
}
type ActivePrompt = {
subagentProgress?: OpenCodeSubagentProgress
requestId: string
operationId: string
sessionId: string
@@ -153,6 +155,7 @@ type ActivePrompt = {
status?: 'pending' | 'in_progress' | 'completed' | 'failed'
rawInput?: unknown
rawOutput?: unknown
contentOutput?: string
retainedInput?: string
retainedOutput?: string
retainedError?: string
@@ -2067,6 +2070,34 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
if (this.options.runtimeId === 'opencode') {
if (!prompt.subagentProgress) {
prompt.subagentProgress = new OpenCodeSubagentProgress(
prompt.requestId, prompt.sessionId
)
for (const tool of prompt.toolCalls.values()) {
if (tool.retainedSubagent) {
prompt.subagentProgress.retain({
...tool.retainedSubagent,
requestId: prompt.requestId, type: 'subagent'
})
}
}
}
const childEvent = update._meta?.goodbuddySubagentEvent
if (childEvent !== undefined) {
const event = prompt.subagentProgress.update(
childEvent, 'toolCallId' in update ? update.toolCallId : undefined
)
if (event?.runtimeCallId) {
const previous = prompt.toolCalls.get(event.runtimeCallId)
prompt.toolCalls.set(event.runtimeCallId, {
...previous, retainedSubagent: event
})
}
return event
}
}
if (
(update.sessionUpdate === 'agent_message_chunk' ||
update.sessionUpdate === 'agent_thought_chunk') &&
@@ -2118,6 +2149,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
update.rawOutput,
retainedMaximum
),
contentOutput:
update.content === undefined || update.content === null
? previous?.contentOutput
: update.content.flatMap((entry) =>
entry.type === 'content' && entry.content.type === 'text'
? [entry.content.text] : []
).join('\n').slice(0, retainedMaximum),
retainedInput:
update.rawInput === undefined
? previous?.retainedInput
@@ -2148,22 +2186,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
callId,
state: toolCall.status ?? 'pending',
input: toolCall.rawInput,
output: toolCall.rawOutput
output: toolCall.contentOutput || toolCall.rawOutput
})
: undefined
if (subagent) {
return subagent
return prompt.subagentProgress?.retain(subagent) ?? subagent
}
if (toolCall.retainedSubagent) {
const retained = toolCall.retainedSubagent
const state =
toolCall.status === 'pending'
? ('queued' as const)
? retained.state
: toolCall.status === 'in_progress'
? ('running' as const)
: toolCall.status ?? retained.state
const detail = safeStringify(
toolCall.rawOutput,
toolCall.contentOutput || toolCall.rawOutput,
retainedMaximum
)
return {
@@ -2191,7 +2229,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
retainedMaximum
) ?? toolCall.retainedInput
const output = safeStringify(
toolCall.rawOutput,
toolCall.contentOutput || toolCall.rawOutput,
retainedMaximum
) ?? toolCall.retainedOutput
return {
+17 -2
View File
@@ -63,7 +63,7 @@ import {
requestProcessTreeTermination,
waitForProcessExit
} from './child-process-termination'
import { toOpenCodeSubagentEvent } from './opencode-subagent'
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
import { promptWithUntrustedConversationHistory } from './runtime-conversation-history'
const STARTUP_TIMEOUT_MS = 30_000
@@ -2217,6 +2217,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>()
const reasoningPartIds = new Set<string>()
const subagentProgress = new OpenCodeSubagentProgress(request.requestId, sessionId)
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
let outputTruncated = false
@@ -2315,6 +2316,18 @@ export class OpenCodeRuntime implements AgentRuntime {
const repliedPermissionIds = new Set<string>()
const reportedMessageIds = new Set<string>()
for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event)
if (
childProgress &&
'sessionID' in event.properties &&
event.properties.sessionID !== sessionId
) {
const task = childProgress.runtimeCallId
? toolStates.get(childProgress.runtimeCallId)
: undefined
if (task) task.subagent = childProgress
yield childProgress
}
if (
event.type === 'message.updated' &&
event.properties.sessionID === sessionId &&
@@ -2424,7 +2437,9 @@ export class OpenCodeRuntime implements AgentRuntime {
...(subagent ? { subagent } : {})
})
if (subagent) {
yield subagent
const retained = subagentProgress.retain(subagent)
toolStates.get(callId)!.subagent = retained
yield retained
} else {
yield {
requestId: request.requestId,
+87 -1
View File
@@ -1,9 +1,76 @@
import { describe, expect, it } from 'vitest'
import { toOpenCodeSubagentEvent } from './opencode-subagent'
import { OpenCodeSubagentProgress, toOpenCodeSubagentEvent } from './opencode-subagent'
const requestId = '3f496642-f47d-4e0a-8944-a32c77b0d6ef'
describe('OpenCode native subagent events', () => {
it('extracts ACP output objects without displaying protocol metadata', () => {
const event = toOpenCodeSubagentEvent({
requestId, callId: 'task', state: 'completed',
input: { subagent_type: 'general', prompt: 'Inspect seed' },
output: {
output: '<task id="child" state="completed">\n<task_result>\n**Done**\n</task_result>\n</task>',
metadata: { sessionId: 'child' }
}
})
expect(event?.output).toBe('**Done**')
})
it('keeps ordered child text, reasoning and tools separate from the final result', () => {
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
const task = (status: string) => ({
type: 'message.part.updated',
properties: {
sessionID: 'parent',
part: {
id: 'task-part', callID: 'task', type: 'tool', tool: 'task',
state: {
status, input: { subagent_type: 'general', prompt: 'Inspect seed' },
metadata: { sessionId: 'child' },
...(status === 'completed' ? { output: 'Final result' } : {})
}
}
}
})
tracker.update(task('running'))
tracker.update({
type: 'message.updated',
properties: { sessionID: 'child', info: { id: 'user', role: 'user' } }
})
expect(tracker.update({
type: 'message.part.updated',
properties: { sessionID: 'child', part: {
id: 'prompt', messageID: 'user', type: 'text', text: 'Do not display prompt'
} }
})).toBeUndefined()
const delta = (sessionID: string, partID: string, text: string) => ({
type: 'message.part.delta',
properties: { sessionID, partID, field: 'text', delta: text }
})
expect(tracker.update(delta('unrelated', 'text', 'Not ours'))).toBeUndefined()
tracker.update(delta('child', 'text', 'Inspecting '))
const first = tracker.update(delta('child', 'text', 'seed'))
expect(first?.progress?.[0]).toMatchObject({ type: 'text', content: 'Inspecting seed' })
tracker.update({
type: 'message.part.updated', properties: { sessionID: 'child', part: {
id: 'thought', type: 'reasoning', text: ''
} }
})
tracker.update(delta('child', 'thought', 'Check contents'))
for (const status of ['running', 'completed']) {
tracker.update({
type: 'message.part.updated', properties: { sessionID: 'child', part: {
id: 'read', callID: 'read', type: 'tool', tool: 'read',
state: { status, input: { path: 'seed.txt' }, output: 'seed contents' }
} }
})
}
const done = tracker.update(task('completed'))
expect(done?.output).toBe('Final result')
expect(done?.progress?.map(b => b.type)).toEqual(['text', 'reasoning', 'tool'])
expect(done?.progress?.[2]).toMatchObject({ tool: { state: 'completed', output: 'seed contents' } })
expect(first?.progress).toHaveLength(1)
})
it('maps Task tool updates to stable bounded subagent identities', () => {
const running = toOpenCodeSubagentEvent({
requestId,
@@ -57,4 +124,23 @@ describe('OpenCode native subagent events', () => {
})
).toBeUndefined()
})
it('routes recovered live child events by the ACP owning Task without new metadata', () => {
const tracker = new OpenCodeSubagentProgress(requestId, 'parent')
tracker.retain({
...toOpenCodeSubagentEvent({
requestId, callId: 'task', state: 'running',
input: { subagent_type: 'general', prompt: 'Read seed' }
})!,
progress: []
})
const event = {
type: 'message.part.delta',
properties: { sessionID: 'child', partID: 'text', field: 'text', delta: 'Resumed progress' }
}
expect(tracker.update(event)).toBeUndefined()
expect(tracker.update(event, 'task')).toMatchObject({
state: 'running', progress: [{ type: 'text', content: 'Resumed progress' }]
})
})
})
+145 -2
View File
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import type { SubagentEvent } from '../../shared/contracts'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import { boundedToolDetail } from './approval-summary'
type OpenCodeSubagentState =
@@ -113,11 +114,19 @@ export function toOpenCodeSubagentEvent(
? 'completed'
: 'failed'
const failed = state === 'failed'
const detail = isRecord(update.output)
? update.output.output ?? update.output.error
: update.output
const result = typeof detail === 'string'
? detail.match(
/^\s*<task\b[^>]*>\s*<task_result>\s*([\s\S]*?)\s*<\/task_result>\s*<\/task>\s*$/u
)?.[1] ?? detail
: detail
const output = failed
? undefined
: boundedDetail(update.output, 16_000)
: boundedDetail(result, 16_000)
const error = failed
? boundedDetail(update.error ?? update.output, 1_000)
? boundedDetail(update.error ?? detail, 1_000)
: undefined
return {
@@ -138,3 +147,137 @@ export function toOpenCodeSubagentEvent(
...(error ? { error } : {})
}
}
/**
* Tracks only sessions explicitly linked by a Task's metadata.sessionId.
* The same snapshots travel through the local SDK and the remote ACP bridge.
*/
export class OpenCodeSubagentProgress {
private readonly roles = new Map<string, string>()
private readonly reasoning = new Set<string>()
private readonly tasks = new Map<string, {
event: SubagentEvent
sessionId?: string
blocks: ConversationMessageBlock[]
}>()
constructor(
private readonly requestId: string,
private readonly parentSessionId?: string
) {}
update(value: unknown, ownerCallId?: string): SubagentEvent | undefined {
if (!isRecord(value) || !isRecord(value.properties)) return undefined
const properties = value.properties
const part = properties.part
if (
value.type === 'message.part.updated' &&
isRecord(part) && part.type === 'tool' &&
part.tool === 'task' && isRecord(part.state) &&
(!this.parentSessionId || properties.sessionID === this.parentSessionId) &&
![...this.tasks.values()].some(
(task) => task.sessionId === properties.sessionID
)
) {
const callId = boundedText(part.callID ?? part.id, 256)
if (!callId) return undefined
const event = toOpenCodeSubagentEvent({
requestId: this.requestId,
callId,
input: part.state.input,
state: part.state.status as OpenCodeSubagentState,
output: part.state.output,
error: part.state.error
})
if (!event) return undefined
const previous = this.tasks.get(callId)
const metadata = part.state.metadata
const task = {
event,
sessionId: (isRecord(metadata)
? boundedText(metadata.sessionId, 256)
: undefined) ?? previous?.sessionId,
blocks: previous?.blocks ?? []
}
this.tasks.set(callId, task)
return { ...event, progress: [...task.blocks] }
}
const task = [...this.tasks.values()].find(
(candidate) => candidate.sessionId === properties.sessionID
) ?? (ownerCallId ? this.tasks.get(ownerCallId) : undefined)
if (!task) return undefined
if (value.type === 'message.updated' && isRecord(properties.info)) {
this.roles.set(String(properties.info.id), String(properties.info.role))
return undefined
}
const messageId = isRecord(part) ? part.messageID : properties.messageID
if (this.roles.get(String(messageId)) === 'user') return undefined
const partId = isRecord(part) ? part.id : properties.partID
if (typeof partId !== 'string') return undefined
const id = deterministicUuid(`${task.event.childTaskId}\0${partId}`)
const index = task.blocks.findIndex((block) => block.id === id)
const previous = task.blocks[index]
let block: ConversationMessageBlock | undefined
if (value.type === 'message.part.updated' && isRecord(part)) {
if (part.type === 'reasoning') this.reasoning.add(id)
if (
(part.type === 'text' || part.type === 'reasoning') &&
typeof part.text === 'string' && part.text &&
part.ignored !== true && part.synthetic !== true
) {
block = { id, type: part.type, content: part.text.slice(0, 16_000) }
} else if (part.type === 'tool' && isRecord(part.state)) {
block = {
id,
type: 'tool',
tool: {
callId: String(part.callID ?? partId).slice(0, 256),
name: String(part.tool).slice(0, 200),
summary: boundedText(part.state.title, 240) ?? String(part.tool),
state: part.state.status === 'error'
? 'failed'
: part.state.status as 'pending' | 'running' | 'completed',
input: boundedDetail(part.state.input, 4_000),
output: boundedDetail(part.state.output, 16_000),
error: boundedDetail(part.state.error, 1_000)
}
}
}
} else if (
value.type === 'message.part.delta' &&
typeof properties.delta === 'string' && properties.delta &&
(properties.field === 'text' || properties.field === 'reasoning')
) {
block = {
id,
type: this.reasoning.has(id) || previous?.type === 'reasoning' || properties.field === 'reasoning'
? 'reasoning'
: 'text',
content: (
(previous?.type === 'text' || previous?.type === 'reasoning'
? previous.content : '') + properties.delta
).slice(0, 16_000)
}
}
if (!block) return undefined
if (index >= 0) task.blocks[index] = block
else task.blocks.push(block)
return { ...task.event, progress: [...task.blocks] }
}
retain(event: SubagentEvent): SubagentEvent {
const task = event.runtimeCallId
? this.tasks.get(event.runtimeCallId)
: undefined
if (!task) {
if (event.runtimeCallId && event.progress) {
this.tasks.set(event.runtimeCallId, {
event, blocks: [...event.progress]
})
}
return event
}
task.event = event
return { ...event, progress: [...task.blocks] }
}
}
+1
View File
@@ -1394,6 +1394,7 @@ function reduceRecoveredAgentEvent(
workMode: event.workMode,
state: event.state,
reason: event.reason,
progress: event.progress,
output: event.output,
error: event.error
}
@@ -23,6 +23,47 @@ afterEach(async () => {
})
describe('getWorkspaceChanges', () => {
it('reads unborn repositories, nested untracked files, index and worktree diffs separately', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
await execute('git', ['init'], { cwd: directory })
expect(await getWorkspaceChanges(directory)).toMatchObject({ available: true, files: [], patch: '' })
await mkdir(join(directory, 'new directory'))
const path = 'new directory/[file].txt'
await writeFile(join(directory, path), 'index\n')
expect((await getWorkspaceChanges(directory)).files).toContainEqual({ path, status: '??' })
expect((await getWorkspaceChanges(directory, path)).patch).toContain('+index')
await execute('git', ['add', '--', path], { cwd: directory })
await writeFile(join(directory, path), 'worktree\n')
const diff = await getWorkspaceChanges(directory, path)
expect(diff).toMatchObject({ available: true })
expect(diff.stagedPatch).toContain('+index')
expect(diff.patch).toContain('-index')
expect(diff.patch).toContain('+worktree')
})
it('reads deleted and renamed paths literally without including unrelated changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
const git = (...args: string[]) => execute('git', args, { cwd: directory })
await git('init')
await writeFile(join(directory, 'old name.txt'), 'original\n')
await writeFile(join(directory, 'other.txt'), 'other\n')
await git('add', '.')
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-m', 'initial')
await git('mv', 'old name.txt', '[new].txt')
await writeFile(join(directory, '[new].txt'), 'edited\n')
await writeFile(join(directory, 'other.txt'), 'unrelated\n')
const renamed = await getWorkspaceChanges(directory, '[new].txt')
expect(renamed.stagedPatch).toContain('rename from old name.txt')
expect(renamed.stagedPatch).toContain('rename to [new].txt')
expect(renamed.patch).toContain('+edited')
expect(renamed.patch).not.toContain('unrelated')
await rm(join(directory, '[new].txt'))
expect((await getWorkspaceChanges(directory, '[new].txt')).patch).toContain('-original')
await git('add', '-u')
expect((await getWorkspaceChanges(directory, 'old name.txt')).stagedPatch).toContain('-original')
})
it('returns tracked and untracked Git workspace changes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-changes-'))
temporaryDirectories.push(directory)
@@ -59,8 +59,8 @@ const previewFileNames = new Set([
export class WorkspaceChangesService {
constructor(private readonly workspace: WorkspaceAccess) {}
getChanges(signal?: AbortSignal): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal })
getChanges(signal?: AbortSignal, path?: string): Promise<WorkspaceChanges> {
return this.workspace.getChanges({ signal, path })
}
async listDirectory(
@@ -158,13 +158,14 @@ export async function resolveWorkspaceEntryPath(
}
export async function getWorkspaceChanges(
workspace: string | WorkspaceAccess
workspace: string | WorkspaceAccess,
path?: string
): Promise<WorkspaceChanges> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).getChanges()
return new WorkspaceChangesService(workspace).getChanges(undefined, path)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).getChanges()
new WorkspaceChangesService(access).getChanges(undefined, path)
)
}
+1
View File
@@ -4157,6 +4157,7 @@ describe('registerIpcHandlers workspace files', () => {
for (const [channel, input] of [
[ipcChannels.workspaceChangesGet, projectId],
[ipcChannels.workspaceFileDiff, { projectId, path: 'deleted.txt' }],
[
ipcChannels.workspaceDirectoryList,
{ projectId, path: '' }
+18 -1
View File
@@ -6601,6 +6601,23 @@ export function registerIpcHandlers(
}
}
)
registerHandler(
ipcChannels.workspaceFileDiff,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const value = workspaceFileRequestSchema.omit({ offsetBytes: true }).parse(input)
const project = assistantDatabase.getProject(value.projectId)
if (project.executionSpace?.kind === 'ssh') {
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
try {
return await getWorkspaceChanges(executionSpace.workspaceAccess, value.path)
} finally {
await executionSpace.workspaceAccess.dispose()
}
}
)
registerHandler(
ipcChannels.workspaceDirectoryList,
async (event, input: unknown) => {
@@ -6652,9 +6669,9 @@ export function registerIpcHandlers(
await requireRemoteProjectsEnabled()
}
const executionSpace = spaceResolver.resolveProject(project)
spaceResolver.assertLocal(executionSpace)
let targetPath: string
try {
spaceResolver.assertLocal(executionSpace)
targetPath = await resolveWorkspaceEntryPath(
executionSpace.rootPath,
value.path,
+42 -19
View File
@@ -775,53 +775,76 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
canonicalRoot,
input.signal
)
const [statusResult, patchResult] = await Promise.all([
runGit(
const git = (args: string[]): Promise<CommandResult> =>
runGit(canonicalRoot, withSafeGitArguments(args), input.signal, filterOverrides)
const statusResult = await runGit(
canonicalRoot,
withSafeGitArguments([
'status',
'--porcelain=v1',
'-z',
'--untracked-files=normal'
]),
input.signal,
filterOverrides
),
runGit(
canonicalRoot,
withSafeGitArguments([
'diff',
'--no-ext-diff',
'--no-textconv',
'--no-color',
'HEAD'
'--untracked-files=all'
]),
input.signal,
filterOverrides
)
])
if (statusResult.code !== 0 || patchResult.code !== 0) {
const detail = statusResult.stderr || patchResult.stderr
if (statusResult.code !== 0) {
const detail = statusResult.stderr
return {
rootPath: this.rootPath,
available: false,
status: '',
patch: '',
files: [],
truncated: statusResult.truncated || patchResult.truncated,
truncated: statusResult.truncated,
error: detail.trim().slice(0, 2_000) || '无法读取 Git 工作区'
}
}
const changedFiles = parseChangedFiles(statusResult.stdout)
const diffArgs = ['diff', '--no-ext-diff', '--no-textconv', '--no-color']
let patchResult: CommandResult
let stagedResult: CommandResult | undefined
if (input.path !== undefined) {
const path = normalizeRelativePath(input.path, false).path
const file = changedFiles.files.find((entry) => entry.path === path)
if (!file) {
throw new Error('File is no longer in the Git changes list')
}
const paths = [...new Set([file.previousPath, path].filter((value): value is string => value !== undefined))]
.map((value) => `:(literal)${value}`)
if (file.status === '??') {
await this.resolveEntryPath(path, 'file')
patchResult = await git([...diffArgs, '--no-index', '--', NULL_DEVICE, path])
if (patchResult.code !== 0 && patchResult.code !== 1) {
throw new Error(patchResult.stderr || 'Git diff failed')
}
} else {
;[stagedResult, patchResult] = await Promise.all([
git([...diffArgs, '--cached', '--', ...paths]),
git([...diffArgs, '--', ...paths])
])
if (stagedResult.code !== 0 || patchResult.code !== 0) {
throw new Error(stagedResult.stderr || patchResult.stderr || 'Git diff failed')
}
}
} else {
const head = await git(['rev-parse', '--verify', '--quiet', 'HEAD'])
patchResult = await git([...diffArgs, ...(head.code === 0 ? ['HEAD'] : ['--cached'])])
}
return {
rootPath: this.rootPath,
available: true,
status: formatChangedFiles(changedFiles.files),
patch: patchResult.stdout,
...(patchResult.code !== 0 && input.path === undefined
? { error: patchResult.stderr.trim().slice(0, 2000) || 'Git diff failed' }
: {}),
...(stagedResult ? { stagedPatch: stagedResult.stdout } : {}),
files: changedFiles.files,
truncated:
statusResult.truncated ||
patchResult.truncated ||
stagedResult?.truncated === true ||
changedFiles.truncated
}
} catch (error) {
@@ -113,6 +113,26 @@ function createAccess(lease: RemoteWorkspaceTransportLease): {
}
describe('RemoteWorkspaceAccess', () => {
it('requests both single-file diff layers and preserves raw Git status', async () => {
const lease = createLease({
getGitStatus: vi.fn(async () => ({
repositoryIdentity: 'repository-1', branch: 'main', truncated: false,
entries: [{ relativePath: '[file].txt', index: 'modified' as const, worktree: 'unmodified' as const, statusCode: 'T.' }]
})),
getGitDiff: vi.fn(async (request) => ({
repositoryIdentity: 'repository-1', patch: request.staged ? 'staged' : 'worktree', byteLength: request.staged ? 6 : 8, truncated: request.staged
}))
})
const { access } = createAccess(lease)
expect(await access.getChanges({ path: '[file].txt' })).toMatchObject({
available: true, patch: 'worktree', stagedPatch: 'staged', truncated: true,
files: [{ path: '[file].txt', status: 'T ' }]
})
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: true }), undefined)
expect(lease.getGitDiff).toHaveBeenCalledWith(expect.objectContaining({ relativePath: '[file].txt', staged: false }), undefined)
expect(lease.statWorkspace).not.toHaveBeenCalled()
await access.dispose()
})
it('lazily validates once and maps bounded workspace methods', async () => {
const lease = createLease({
listWorkspace: vi.fn(async () => ({
+13 -7
View File
@@ -247,7 +247,7 @@ function mapChangedFiles(
: `${gitStatusCharacter(entry.index)}${gitStatusCharacter(entry.worktree)}`
return {
path: entry.relativePath,
status,
status: entry.statusCode?.replaceAll('.', ' ') ?? status,
...(entry.originalRelativePath
? { previousPath: entry.originalRelativePath }
: {})
@@ -577,8 +577,7 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
for (let attempt = 0; attempt < 2; attempt += 1) {
input.signal?.throwIfAborted()
const reference = this.reference(opened)
const [status, diff] = await Promise.all([
opened.lease
const status = await opened.lease
.getGitStatus(
remoteGitStatusRequestSchema.parse({
...reference,
@@ -590,12 +589,13 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
)
.then((result) =>
remoteGitStatusResultSchema.parse(result)
),
opened.lease
)
const readDiff = (staged: boolean) => opened.lease
.getGitDiff(
remoteGitDiffRequestSchema.parse({
...reference,
staged: false,
staged,
...(input.path === undefined ? {} : { relativePath: input.path }),
maximumBytes:
REMOTE_WORKSPACE_LIMITS.maximumGitDiffBytes
}),
@@ -604,8 +604,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
.then((result) =>
remoteGitDiffResultSchema.parse(result)
)
const [diff, stagedDiff] = await Promise.all([
readDiff(false),
input.path === undefined ? undefined : readDiff(true)
])
if (status.repositoryIdentity !== diff.repositoryIdentity) {
if (status.repositoryIdentity !== diff.repositoryIdentity ||
(stagedDiff && status.repositoryIdentity !== stagedDiff.repositoryIdentity)) {
if (attempt === 0) {
continue
}
@@ -617,10 +621,12 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
available: true,
status: formatChangedFiles(files),
patch: diff.patch,
...(stagedDiff ? { stagedPatch: stagedDiff.patch } : {}),
files,
truncated:
status.truncated ||
diff.truncated ||
stagedDiff?.truncated === true ||
diff.nextCursor !== undefined
}
}
+1
View File
@@ -88,6 +88,7 @@ export type SearchPage = {
}
export type WorkspaceChangesInput = {
path?: string
signal?: AbortSignal
}