Address Claude review follow-ups
This commit is contained in:
@@ -18,6 +18,8 @@ try {
|
||||
// ENOENT (no .env) is fine — rely on the ambient environment.
|
||||
}
|
||||
|
||||
// Resolve relative cordis.yml paths from the repo root no matter where the
|
||||
// editor launches this demo command.
|
||||
process.chdir(fileURLToPath(new URL('../..', import.meta.url)))
|
||||
|
||||
const ctx = new Context()
|
||||
|
||||
@@ -100,6 +100,10 @@ function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
}
|
||||
|
||||
function sameWorkspaceCwd(left: string, right: string): boolean {
|
||||
return resolvePath(left) === resolvePath(right)
|
||||
}
|
||||
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
export interface AcpConfig {
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
@@ -466,13 +470,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// (An id unknown to `list()` falls through to resume, which rejects with
|
||||
// the backend's not-found error.)
|
||||
const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
|
||||
if (meta !== undefined && (meta.cwd === undefined || !isAbsolute(meta.cwd))) {
|
||||
throw invalidParams(
|
||||
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
|
||||
)
|
||||
}
|
||||
if (meta !== undefined && meta.cwd !== params.cwd) {
|
||||
throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${meta.cwd}, requested ${params.cwd}`)
|
||||
if (meta !== undefined) {
|
||||
const persistedCwd = meta.cwd
|
||||
if (persistedCwd === undefined || !isAbsolute(persistedCwd)) {
|
||||
throw invalidParams(
|
||||
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
|
||||
)
|
||||
}
|
||||
if (!sameWorkspaceCwd(persistedCwd, params.cwd)) {
|
||||
throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
|
||||
}
|
||||
}
|
||||
const agent = await agents.resume({
|
||||
agentId: params.sessionId,
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('acp bridge — session/load replay', () => {
|
||||
.rejects.toThrow(/cwd mismatch/)
|
||||
expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
|
||||
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: otherCwd, mcpServers: [] })
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
expect(loader.ctx.agents.get('elsewhere')!.session.header.cwd).toBe(otherCwd)
|
||||
})
|
||||
@@ -190,6 +190,13 @@ describe('acp bridge — session/load replay', () => {
|
||||
.rejects.toThrow(/absolute/)
|
||||
})
|
||||
|
||||
it('lets persistence reject a load for an unknown id after metadata lookup misses', async () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'missing', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/Internal error/)
|
||||
})
|
||||
|
||||
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
|
||||
// A legacy / externally-created session log with no header.cwd. The bridge
|
||||
// must reject the load rather than accept it and let bash silently fall back
|
||||
|
||||
@@ -502,6 +502,11 @@ async function runStep(
|
||||
if (stepError) throw stepError
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
let message: Message = withoutToolCalls(assembler.message())
|
||||
message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)))
|
||||
if (message.content.length > 0) {
|
||||
session.append('assistant/message', { turn, step, content: message.content })
|
||||
}
|
||||
if (assembler.usage) {
|
||||
session.append('usage', { turn, step, usage: assembler.usage })
|
||||
}
|
||||
@@ -566,6 +571,10 @@ async function runStep(
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
function withoutToolCalls(message: Message): Message {
|
||||
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
export function lastTurnNumber(session: Session): number {
|
||||
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
||||
|
||||
@@ -385,6 +385,10 @@ describe('agent loop', () => {
|
||||
|
||||
expect(steps).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(adapter.requests[1]!.messages).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }] },
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
@@ -436,10 +440,38 @@ describe('agent loop', () => {
|
||||
|
||||
expect(executions).toBe(0)
|
||||
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
|
||||
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
|
||||
const callId = CallId('c1')
|
||||
const adapter = new MockAdapter([[
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial text' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]])
|
||||
const ctx = await harness(adapter)
|
||||
let stepResults = 0
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
|
||||
stepResults += 1
|
||||
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(stepResults).toBe(1)
|
||||
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
|
||||
expect(agent.session.deriveMessages()).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }] },
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'max-tokens' }])
|
||||
})
|
||||
|
||||
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
|
||||
|
||||
@@ -448,6 +448,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
// Dispose must reach quiescence: await every init + final drain, then close
|
||||
// the database, BEFORE returning, so no write lands after teardown.
|
||||
ctx.effect(() => async () => {
|
||||
let disposeError: unknown
|
||||
try {
|
||||
const errors = [
|
||||
...await settledErrors(this.inits.values()),
|
||||
@@ -457,9 +458,20 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'session-persistence-sqlite dispose failed')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
disposeError = error
|
||||
throw error
|
||||
} finally {
|
||||
await this.ready
|
||||
this.db.close()
|
||||
try {
|
||||
await this.ready
|
||||
this.db.close()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- open/close failure racing disposal is a defensive teardown edge */
|
||||
if (disposeError === undefined) throw error
|
||||
// Opening/closing the database can only add teardown context here; keep
|
||||
// the already-captured init/flush/chain AggregateError as the primary
|
||||
// disposal failure instead of masking it from callers.
|
||||
}
|
||||
}
|
||||
}, 'session-persistence-sqlite write path')
|
||||
|
||||
|
||||
@@ -116,9 +116,12 @@ export class SystemPrompt extends Service {
|
||||
|
||||
/**
|
||||
* Assemble the current prompt (sections sorted by order, tools collected
|
||||
* from all providers). Runs through the `system-prompt/assemble` waterfall,
|
||||
* giving listeners the opportunity to mutate or replace the assembly before
|
||||
* it reaches the model. Await the result before reading the assembly values —
|
||||
* from all providers). Section records are top-level clones (the `text`
|
||||
* provider may be a function and is intentionally shared); tool schemas are
|
||||
* deep-cloned because adapters and request waterfalls may mutate schema
|
||||
* objects. Runs through the `system-prompt/assemble` waterfall, giving
|
||||
* listeners the opportunity to mutate or replace the assembly before it
|
||||
* reaches the model. Await the result before reading the assembly values —
|
||||
* waterfall listeners may be async.
|
||||
*/
|
||||
assemble(): Promise<PromptAssembly> {
|
||||
|
||||
@@ -332,11 +332,12 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/execute` waterfall. If the tool
|
||||
* is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. If the tool throws, the error is caught and returned as
|
||||
* an `isError` result so the loop never sees an uncaught exception; a thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
||||
* Execute one tool call through the `tools/execute` waterfall. If the tool is
|
||||
* not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. If the tool or a waterfall listener throws, the error is
|
||||
* caught and returned as an `isError` result so the loop records a failed tool
|
||||
* call instead of failing the whole turn; a thrown {@link HarnessError}
|
||||
* surfaces its `{ name, code }` on the result.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user