mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(daemon): surface workspace memory task error details (#6431)
* fix(daemon): surface workspace memory task error details * fix(daemon): harden workspace memory error details * fix(daemon): cover workspace memory detail edge cases * fix(daemon): harden workspace memory detail extraction * fix(daemon): harden workspace memory failure diagnostics * fix(daemon): sanitize workspace memory diagnostics * fix(daemon): sanitize workspace memory debug logs * fix(daemon): preserve sanitized memory task stack logs * fix(daemon): harden memory diagnostics redaction * fix(daemon): refine memory task diagnostics * fix(daemon): preserve workspace memory stack diagnostics * fix(daemon): harden workspace memory diagnostics * fix(daemon): guard workspace memory error code extraction * fix(daemon): share workspace memory extraction logging * fix(daemon): suppress workspace memory unavailable details * fix(daemon): clarify workspace memory unavailable timeout logs * fix(daemon): preserve memory diagnostic separators * fix(daemon): harden workspace memory failure handling * fix(daemon): redact split platform tokens * fix(daemon): redact split memory error credentials * fix(daemon): harden workspace memory error diagnostics
This commit is contained in:
parent
ba709c2c2c
commit
6e48077532
7 changed files with 1877 additions and 80 deletions
|
|
@ -54,6 +54,15 @@ const { mockRunManagedAutoMemoryDream, mockRunManagedRememberByAgent } =
|
|||
mockRunManagedRememberByAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockDebugLogger } = vi.hoisted(() => ({
|
||||
mockDebugLogger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@agentclientprotocol/sdk', () => ({
|
||||
AgentSideConnection: vi.fn().mockImplementation(() => ({
|
||||
get closed() {
|
||||
|
|
@ -119,12 +128,7 @@ vi.mock('node:stream', async (importOriginal) => {
|
|||
|
||||
// Mock core dependencies
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
createDebugLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
createDebugLogger: () => mockDebugLogger,
|
||||
registerAcpEventLoopLagGauge: vi.fn(),
|
||||
startEventLoopLagMonitor: vi.fn(() => ({
|
||||
snapshot: vi.fn(() => ({
|
||||
|
|
@ -3431,6 +3435,354 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rejects workspace memory remember when the bridge reports managed memory unavailable', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
let rejection: unknown;
|
||||
try {
|
||||
await agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
});
|
||||
} catch (err) {
|
||||
rejection = err;
|
||||
}
|
||||
|
||||
expect(rejection).toMatchObject({
|
||||
code: -32009,
|
||||
message: 'Managed memory is unavailable for this daemon workspace',
|
||||
data: { errorKind: 'managed_memory_unavailable' },
|
||||
});
|
||||
expect(
|
||||
(rejection as { data: Record<string, unknown> }).data,
|
||||
).not.toHaveProperty('details');
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember failed:',
|
||||
expect.objectContaining({
|
||||
code: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('includes details for workspace memory remember failures', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue(
|
||||
new Error('remember agent stopped early'),
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory remember failed',
|
||||
data: {
|
||||
errorKind: 'remember_failed',
|
||||
details: 'remember agent stopped early',
|
||||
},
|
||||
});
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('logs sanitized details for workspace memory failures', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue(
|
||||
new Error('Authorization: Bearer secret-token-value'),
|
||||
);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory remember failed',
|
||||
data: {
|
||||
errorKind: 'remember_failed',
|
||||
details: 'Authorization: <redacted>',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember failed:',
|
||||
expect.objectContaining({
|
||||
code: 'remember_failed',
|
||||
details: 'Authorization: <redacted>',
|
||||
stack: expect.stringContaining('Authorization: <redacted>'),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
|
||||
'secret-token-value',
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('falls back when workspace memory error code extraction throws', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('code getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
mockRunManagedRememberByAgent.mockRejectedValue(err);
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory remember failed',
|
||||
data: { errorKind: 'remember_failed' },
|
||||
});
|
||||
expect(
|
||||
(error as { data?: Record<string, unknown> }).data,
|
||||
).not.toHaveProperty('details');
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to extract workspace memory error code:',
|
||||
{ extractionError: 'code getter failed' },
|
||||
);
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember failed:',
|
||||
expect.objectContaining({
|
||||
code: 'remember_failed',
|
||||
details: '<details unavailable>',
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('uses remember-specific error codes for workspace memory remember timeouts', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue(new Error('late abort'));
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const timeoutSpy = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(controller.signal);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory remember timed out',
|
||||
data: { errorKind: 'remember_timeout', details: 'late abort' },
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'remember_timeout',
|
||||
details: 'late abort',
|
||||
stack: expect.stringContaining('late abort'),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves timeout codes for timed out workspace memory remember unavailable errors', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const timeoutSpy = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(controller.signal);
|
||||
|
||||
try {
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory remember timed out',
|
||||
data: {
|
||||
errorKind: 'remember_timeout',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'remember_timeout',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('omits details for workspace memory failures without a detail source', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
});
|
||||
mockRunManagedRememberByAgent.mockRejectedValue({
|
||||
code: 'remember_failed',
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryRemember, {
|
||||
content: 'Remember me.',
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32099,
|
||||
data: { errorKind: 'remember_failed' },
|
||||
});
|
||||
expect(
|
||||
(error as { data?: Record<string, unknown> }).data,
|
||||
).not.toHaveProperty('details');
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember failed:',
|
||||
expect.objectContaining({ details: '<details unavailable>' }),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('runs workspace memory forget without requiring a session', async () => {
|
||||
const forget = vi.fn().mockResolvedValue({
|
||||
systemMessage: 'Forgot 1 entry.',
|
||||
|
|
@ -3556,8 +3908,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory forget failed',
|
||||
data: { errorKind: 'forget_failed' },
|
||||
data: { errorKind: 'forget_failed', details: 'boom' },
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory forget failed:',
|
||||
expect.objectContaining({
|
||||
code: 'forget_failed',
|
||||
details: 'boom',
|
||||
stack: expect.stringContaining('boom'),
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rejects workspace memory forget when the bridge reports managed memory unavailable', async () => {
|
||||
const forget = vi.fn().mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
getMemoryManager: vi.fn().mockReturnValue({ forget }),
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
}),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
|
||||
query: 'old preference',
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32009,
|
||||
message: 'Managed memory is unavailable for this daemon workspace',
|
||||
data: { errorKind: 'managed_memory_unavailable' },
|
||||
});
|
||||
expect(
|
||||
(error as { data?: Record<string, unknown> }).data,
|
||||
).not.toHaveProperty('details');
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory forget failed:',
|
||||
expect.objectContaining({
|
||||
code: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
|
|
@ -3600,8 +4015,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory forget timed out',
|
||||
data: { errorKind: 'forget_timeout' },
|
||||
data: { errorKind: 'forget_timeout', details: 'late abort' },
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory forget timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'forget_timeout',
|
||||
details: 'late abort',
|
||||
stack: expect.stringContaining('late abort'),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves timeout codes for timed out workspace memory forget unavailable errors', async () => {
|
||||
const forget = vi.fn().mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
getMemoryManager: vi.fn().mockReturnValue({ forget }),
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
}),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const timeoutSpy = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(controller.signal);
|
||||
|
||||
try {
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryForget, {
|
||||
query: 'old preference',
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory forget timed out',
|
||||
data: {
|
||||
errorKind: 'forget_timeout',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory forget timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'forget_timeout',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
|
|
@ -3690,8 +4176,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory dream failed',
|
||||
data: { errorKind: 'dream_failed' },
|
||||
data: { errorKind: 'dream_failed', details: 'boom' },
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory dream failed:',
|
||||
expect.objectContaining({
|
||||
code: 'dream_failed',
|
||||
details: 'boom',
|
||||
stack: expect.stringContaining('boom'),
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
});
|
||||
|
||||
it('rejects workspace memory dream when the bridge reports managed memory unavailable', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
}),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
|
||||
});
|
||||
mockRunManagedAutoMemoryDream.mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32009,
|
||||
message: 'Managed memory is unavailable for this daemon workspace',
|
||||
data: { errorKind: 'managed_memory_unavailable' },
|
||||
});
|
||||
expect(
|
||||
(error as { data?: Record<string, unknown> }).data,
|
||||
).not.toHaveProperty('details');
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory dream failed:',
|
||||
expect.objectContaining({
|
||||
code: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
|
|
@ -3731,8 +4277,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
|||
).rejects.toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory dream timed out',
|
||||
data: { errorKind: 'dream_timeout' },
|
||||
data: { errorKind: 'dream_timeout', details: 'late abort' },
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory dream timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'dream_timeout',
|
||||
details: 'late abort',
|
||||
stack: expect.stringContaining('late abort'),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
await agentPromise;
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves timeout codes for timed out workspace memory dream unavailable errors', async () => {
|
||||
Object.assign(mockConfig, {
|
||||
isManagedMemoryAvailable: vi.fn().mockReturnValue(true),
|
||||
getProjectRoot: vi.fn().mockReturnValue('/workspace'),
|
||||
getChatRecordingService: vi.fn().mockReturnValue({
|
||||
recordUiTelemetryEvent: vi.fn(),
|
||||
}),
|
||||
getTranscriptPath: vi.fn().mockReturnValue('/tmp/transcript.jsonl'),
|
||||
});
|
||||
mockRunManagedAutoMemoryDream.mockRejectedValue({
|
||||
data: {
|
||||
errorKind: 'managed_memory_unavailable',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
|
||||
const agentPromise = runAcpAgent(
|
||||
mockConfig,
|
||||
makeSessionSettings(),
|
||||
mockArgv,
|
||||
);
|
||||
await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined());
|
||||
const agent = capturedAgentFactory!({
|
||||
get closed() {
|
||||
return mockConnectionState.promise;
|
||||
},
|
||||
}) as AgentLike;
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const timeoutSpy = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockReturnValue(controller.signal);
|
||||
|
||||
try {
|
||||
const error = await agent
|
||||
.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream, {})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: -32099,
|
||||
message: 'Workspace memory dream timed out',
|
||||
data: {
|
||||
errorKind: 'dream_timeout',
|
||||
details: 'memory service stopped',
|
||||
},
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory dream timed out:',
|
||||
expect.objectContaining({
|
||||
code: 'dream_timeout',
|
||||
details: 'memory service stopped',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
timeoutSpy.mockRestore();
|
||||
mockConnectionState.resolve();
|
||||
|
|
|
|||
|
|
@ -168,7 +168,12 @@ import {
|
|||
buildDisabledSkillNamesProvider,
|
||||
loadCliConfig,
|
||||
} from '../config/config.js';
|
||||
import { extractRememberErrorCode } from '../serve/workspace-remember-errors.js';
|
||||
import {
|
||||
createWorkspaceMemoryExtractionErrorLogger,
|
||||
shouldSuppressRememberErrorDetails,
|
||||
workspaceMemoryFailureCode,
|
||||
workspaceMemoryFailureDiagnostics,
|
||||
} from '../serve/workspace-remember-errors.js';
|
||||
import { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js';
|
||||
import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js';
|
||||
import { Session, buildAvailableCommandsSnapshot } from './session/Session.js';
|
||||
|
|
@ -272,6 +277,19 @@ const BTW_CHILD_TIMEOUT_MS = 55_000;
|
|||
// Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts.
|
||||
const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000;
|
||||
|
||||
function workspaceMemoryErrorData(
|
||||
code: string,
|
||||
diagnostics: { details?: string },
|
||||
): { errorKind: string; details?: string } {
|
||||
return {
|
||||
errorKind: code,
|
||||
...(diagnostics.details ? { details: diagnostics.details } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const logWorkspaceMemoryExtractionError =
|
||||
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
|
||||
|
||||
function parseAcpLocalReadRootsEnv(
|
||||
raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV],
|
||||
): string[] {
|
||||
|
|
@ -5812,10 +5830,12 @@ class QwenAgent implements Agent {
|
|||
const childSignal = AbortSignal.timeout(
|
||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||
);
|
||||
let projectRoot = '<unknown>';
|
||||
try {
|
||||
projectRoot = this.config.getProjectRoot();
|
||||
const result = await runManagedRememberByAgent({
|
||||
config: this.config,
|
||||
projectRoot: this.config.getProjectRoot(),
|
||||
projectRoot,
|
||||
content: content.trim(),
|
||||
contextMode,
|
||||
abortSignal: childSignal,
|
||||
|
|
@ -5825,15 +5845,36 @@ class QwenAgent implements Agent {
|
|||
if (err instanceof RequestError) {
|
||||
throw err;
|
||||
}
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'remember_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
if (childSignal.aborted) {
|
||||
const timeoutCode = 'remember_timeout';
|
||||
debugLogger.error('Workspace memory remember timed out:', {
|
||||
projectRoot,
|
||||
code: timeoutCode,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
'Workspace memory remember timed out',
|
||||
{ errorKind: 'remember_timeout' },
|
||||
workspaceMemoryErrorData(timeoutCode, diagnostics),
|
||||
);
|
||||
}
|
||||
const code = extractRememberErrorCode(err);
|
||||
if (code === 'managed_memory_unavailable') {
|
||||
debugLogger.error('Workspace memory remember failed:', {
|
||||
projectRoot,
|
||||
code,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
if (shouldSuppressRememberErrorDetails(code)) {
|
||||
throw new RequestError(
|
||||
-32009,
|
||||
'Managed memory is unavailable for this daemon workspace',
|
||||
|
|
@ -5842,12 +5883,8 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: 'Workspace memory remember failed',
|
||||
{
|
||||
errorKind: code,
|
||||
},
|
||||
'Workspace memory remember failed',
|
||||
workspaceMemoryErrorData(code, diagnostics),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5879,8 +5916,9 @@ class QwenAgent implements Agent {
|
|||
const childSignal = AbortSignal.timeout(
|
||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||
);
|
||||
let projectRoot = '<unknown>';
|
||||
try {
|
||||
const projectRoot = this.config.getProjectRoot();
|
||||
projectRoot = this.config.getProjectRoot();
|
||||
const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config);
|
||||
const result = await this.config
|
||||
.getMemoryManager()
|
||||
|
|
@ -5900,26 +5938,47 @@ class QwenAgent implements Agent {
|
|||
if (err instanceof RequestError) {
|
||||
throw err;
|
||||
}
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'forget_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
if (childSignal.aborted) {
|
||||
const timeoutCode = 'forget_timeout';
|
||||
debugLogger.error('Workspace memory forget timed out:', {
|
||||
projectRoot,
|
||||
code: timeoutCode,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
'Workspace memory forget timed out',
|
||||
{
|
||||
errorKind: 'forget_timeout',
|
||||
},
|
||||
workspaceMemoryErrorData(timeoutCode, diagnostics),
|
||||
);
|
||||
}
|
||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
||||
if (code === 'managed_memory_unavailable') {
|
||||
debugLogger.error('Workspace memory forget failed:', {
|
||||
projectRoot,
|
||||
code,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
if (shouldSuppressRememberErrorDetails(code)) {
|
||||
throw new RequestError(
|
||||
-32009,
|
||||
'Managed memory is unavailable for this daemon workspace',
|
||||
{ errorKind: 'managed_memory_unavailable' },
|
||||
);
|
||||
}
|
||||
throw new RequestError(-32099, 'Workspace memory forget failed', {
|
||||
errorKind: code,
|
||||
});
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
'Workspace memory forget failed',
|
||||
workspaceMemoryErrorData(code, diagnostics),
|
||||
);
|
||||
}
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: {
|
||||
|
|
@ -5934,9 +5993,11 @@ class QwenAgent implements Agent {
|
|||
const childSignal = AbortSignal.timeout(
|
||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||
);
|
||||
let projectRoot = '<unknown>';
|
||||
try {
|
||||
projectRoot = this.config.getProjectRoot();
|
||||
const result = await runManagedAutoMemoryDream(
|
||||
this.config.getProjectRoot(),
|
||||
projectRoot,
|
||||
new Date(),
|
||||
createHiddenWorkspaceMemoryConfig(this.config),
|
||||
childSignal,
|
||||
|
|
@ -5955,22 +6016,47 @@ class QwenAgent implements Agent {
|
|||
if (err instanceof RequestError) {
|
||||
throw err;
|
||||
}
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'dream_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
if (childSignal.aborted) {
|
||||
throw new RequestError(-32099, 'Workspace memory dream timed out', {
|
||||
errorKind: 'dream_timeout',
|
||||
const timeoutCode = 'dream_timeout';
|
||||
debugLogger.error('Workspace memory dream timed out:', {
|
||||
projectRoot,
|
||||
code: timeoutCode,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
'Workspace memory dream timed out',
|
||||
workspaceMemoryErrorData(timeoutCode, diagnostics),
|
||||
);
|
||||
}
|
||||
const code = extractRememberErrorCode(err, 'dream_failed');
|
||||
if (code === 'managed_memory_unavailable') {
|
||||
debugLogger.error('Workspace memory dream failed:', {
|
||||
projectRoot,
|
||||
code,
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
if (shouldSuppressRememberErrorDetails(code)) {
|
||||
throw new RequestError(
|
||||
-32009,
|
||||
'Managed memory is unavailable for this daemon workspace',
|
||||
{ errorKind: 'managed_memory_unavailable' },
|
||||
);
|
||||
}
|
||||
throw new RequestError(-32099, 'Workspace memory dream failed', {
|
||||
errorKind: code,
|
||||
});
|
||||
throw new RequestError(
|
||||
-32099,
|
||||
'Workspace memory dream failed',
|
||||
workspaceMemoryErrorData(code, diagnostics),
|
||||
);
|
||||
}
|
||||
}
|
||||
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractRememberErrorCode } from './workspace-remember-errors.js';
|
||||
import {
|
||||
extractRememberErrorCode,
|
||||
extractRememberErrorDetails,
|
||||
extractRememberErrorStack,
|
||||
shouldSuppressRememberErrorDetails,
|
||||
workspaceMemoryFailureCode,
|
||||
workspaceMemoryFailureDiagnostics,
|
||||
} from './workspace-remember-errors.js';
|
||||
|
||||
describe('extractRememberErrorCode', () => {
|
||||
it('extracts remember error codes from common error shapes', () => {
|
||||
|
|
@ -25,9 +32,553 @@ describe('extractRememberErrorCode', () => {
|
|||
cause: { code: 'remember_timeout' },
|
||||
}),
|
||||
).toBe('remember_timeout');
|
||||
expect(
|
||||
extractRememberErrorCode({
|
||||
cause: { cause: { code: 'remember_path_escape' } },
|
||||
}),
|
||||
).toBe('remember_path_escape');
|
||||
expect(extractRememberErrorCode(new Error('boom'))).toBe('remember_failed');
|
||||
expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe(
|
||||
'forget_failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('limits cause traversal depth', () => {
|
||||
const root: Record<string, unknown> = {};
|
||||
let current = root;
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const next: Record<string, unknown> = {};
|
||||
current['cause'] = next;
|
||||
current = next;
|
||||
}
|
||||
current['code'] = 'too_deep';
|
||||
|
||||
expect(extractRememberErrorCode(root)).toBe('remember_failed');
|
||||
});
|
||||
|
||||
it('falls back when code extraction throws', () => {
|
||||
const extractionErrors: Array<{ target: string; message: string }> = [];
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('code getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
workspaceMemoryFailureCode(err, 'dream_failed', (target, cause) =>
|
||||
extractionErrors.push({
|
||||
target,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
),
|
||||
).toBe('dream_failed');
|
||||
expect(extractionErrors).toEqual([
|
||||
{ target: 'code', message: 'code getter failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps fallback behavior when code extraction logging throws', () => {
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('code getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
workspaceMemoryFailureCode(err, 'dream_failed', () => {
|
||||
throw new Error('logger failed');
|
||||
}),
|
||||
).toBe('dream_failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRememberErrorDetails', () => {
|
||||
it('extracts details from common error shapes', () => {
|
||||
expect(extractRememberErrorDetails(new Error('boom'))).toBe('boom');
|
||||
expect(
|
||||
extractRememberErrorDetails({
|
||||
data: { details: 'agent stopped because max turns exceeded' },
|
||||
}),
|
||||
).toBe('agent stopped because max turns exceeded');
|
||||
expect(extractRememberErrorDetails({ data: 'raw data detail' })).toBe(
|
||||
'raw data detail',
|
||||
);
|
||||
expect(
|
||||
extractRememberErrorDetails({
|
||||
data: 'ERR_BRIDGE_INTERNAL',
|
||||
message: 'Connection to memory service refused',
|
||||
}),
|
||||
).toBe('Connection to memory service refused');
|
||||
expect(
|
||||
extractRememberErrorDetails({
|
||||
data: { message: 'provider rejected the request' },
|
||||
}),
|
||||
).toBe('provider rejected the request');
|
||||
expect(
|
||||
extractRememberErrorDetails({
|
||||
cause: new Error('nested failure reason'),
|
||||
}),
|
||||
).toBe('nested failure reason');
|
||||
expect(extractRememberErrorDetails({ cause: 'string cause reason' })).toBe(
|
||||
'string cause reason',
|
||||
);
|
||||
expect(extractRememberErrorDetails('raw string error')).toBe(
|
||||
'raw string error',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers bridge details over generic messages', () => {
|
||||
expect(
|
||||
extractRememberErrorDetails({
|
||||
data: { details: 'specific bridge reason' },
|
||||
message: 'generic message',
|
||||
}),
|
||||
).toBe('specific bridge reason');
|
||||
});
|
||||
|
||||
it('redacts credentials before exposing details', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('Authorization: Bearer secret-token-value'),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
});
|
||||
|
||||
it('normalizes hidden separators before redacting credentials', () => {
|
||||
for (const separator of [
|
||||
'\u00ad',
|
||||
'\u061c',
|
||||
'\u180e',
|
||||
'\u200b',
|
||||
'\u2060',
|
||||
'\u2064',
|
||||
]) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Authorization: Bearer${separator}secret-token-value`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts credentials with hidden separators inside token values', () => {
|
||||
for (const separator of [
|
||||
'\u00ad',
|
||||
'\u061c',
|
||||
'\u180e',
|
||||
'\u200b',
|
||||
'\u2060',
|
||||
'\u2064',
|
||||
]) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Authorization: Bearer secret${separator}token-value`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret');
|
||||
expect(details).not.toContain('token-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts bearer tokens split by control characters', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('Authorization: Bearer secret\x01token-value'),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret');
|
||||
expect(details).not.toContain('token-value');
|
||||
});
|
||||
|
||||
it('redacts bearer tokens split by unicode space separators', () => {
|
||||
for (const separator of [
|
||||
'\u00a0',
|
||||
'\u1680',
|
||||
'\u2000',
|
||||
'\u2009',
|
||||
'\u200a',
|
||||
'\u202f',
|
||||
'\u205f',
|
||||
'\u3000',
|
||||
]) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Bearer sk-AAAAAAAAAA${separator}BBBBBBBBBBBBBBB`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Bearer <redacted>');
|
||||
expect(details).not.toContain('AAAAAAAAAA');
|
||||
expect(details).not.toContain('BBBBBBBBBBBBBBB');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts bearer tokens after unicode space separators', () => {
|
||||
for (const separator of ['\u00a0', '\u2009', '\u202f']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Bearer${separator}secret-token-value`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Bearer <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes unicode space separators without collapsing words', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('missing column\u00a0name'),
|
||||
);
|
||||
|
||||
expect(details).toBe('missing column name');
|
||||
});
|
||||
|
||||
it('redacts bare tokens split by invisible characters', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('OpenAI key sk-AAAAAAAAAA\u2062BBBBBBBBBBBBBBB'),
|
||||
);
|
||||
|
||||
expect(details).toBe('OpenAI key sk-<redacted>');
|
||||
expect(details).not.toContain('AAAAAAAAAA');
|
||||
expect(details).not.toContain('BBBBBBBBBBBBBBB');
|
||||
});
|
||||
|
||||
it('redacts platform tokens split by invisible characters', () => {
|
||||
for (const prefix of [
|
||||
'ghp_',
|
||||
'gho_',
|
||||
'ghs_',
|
||||
'ghu_',
|
||||
'github_pat_',
|
||||
'glpat-',
|
||||
'xoxb-',
|
||||
'xoxp-',
|
||||
]) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Platform token ${prefix}AAAAAAAAAA\u200bBBBBBBBBBBBBBBB`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Platform token <redacted>');
|
||||
expect(details).not.toContain('AAAAAAAAAA');
|
||||
expect(details).not.toContain('BBBBBBBBBBBBBBB');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts platform tokens split by unicode space separators', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('Platform token ghp_AAAAAAAAAA\u00a0BBBBBBBBBBBBBBB'),
|
||||
);
|
||||
|
||||
expect(details).toBe('Platform token <redacted>');
|
||||
expect(details).not.toContain('AAAAAAAAAA');
|
||||
expect(details).not.toContain('BBBBBBBBBBBBBBB');
|
||||
});
|
||||
|
||||
it('redacts AWS access keys split by credential separators', () => {
|
||||
for (const separator of ['\x01', '\u00a0', '\u200b']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`AWS key AKIA12345678${separator}90123456`),
|
||||
);
|
||||
|
||||
expect(details).toBe('AWS key <redacted>');
|
||||
expect(details).not.toContain('12345678');
|
||||
expect(details).not.toContain('90123456');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts secret assignments split by credential separators', () => {
|
||||
for (const separator of ['\x01', '\u00a0', '\u200b']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`token=aaaaaaaaaa${separator}bbbbbbbb`),
|
||||
);
|
||||
|
||||
expect(details).toBe('token=<redacted>');
|
||||
expect(details).not.toContain('aaaaaaaaaa');
|
||||
expect(details).not.toContain('bbbbbbbb');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts env secret assignments split by credential separators', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('QWEN_DAEMON_TOKEN=AAAAAAAAAA\u00a0BBBBBBBB'),
|
||||
);
|
||||
|
||||
expect(details).toBe('QWEN_DAEMON_TOKEN=<redacted>');
|
||||
expect(details).not.toContain('AAAAAAAAAA');
|
||||
expect(details).not.toContain('BBBBBBBB');
|
||||
});
|
||||
|
||||
it('redacts URL credentials split by credential separators', () => {
|
||||
for (const separator of ['\x01', '\u00a0', '\u200b']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(
|
||||
`postgresql://admin:S3cret${separator}P4ssw0rd@db.internal:5432/mydb`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(details).toBe('postgresql://<redacted>@db.internal:5432/mydb');
|
||||
expect(details).not.toContain('admin');
|
||||
expect(details).not.toContain('S3cret');
|
||||
expect(details).not.toContain('P4ssw0rd');
|
||||
}
|
||||
});
|
||||
|
||||
it('redacts bare bearer tokens separated by invisible characters', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('Bearer\u200BeyJhbGciOiABCDEFGHIJKLMN'),
|
||||
);
|
||||
|
||||
expect(details).toBe('Bearer <redacted>');
|
||||
expect(details).not.toContain('eyJhbGciOiABCDEFGHIJKLMN');
|
||||
});
|
||||
|
||||
it('redacts QQBot tokens separated by invisible characters', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('QQBot\u200Bsecret-token-value'),
|
||||
);
|
||||
|
||||
expect(details).toBe('QQBot <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
});
|
||||
|
||||
it('normalizes line separators before redacting credentials', () => {
|
||||
for (const separator of ['\u2028', '\u2029']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Authorization: Bearer${separator}secret-token-value`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes bidi isolation characters before redacting credentials', () => {
|
||||
for (const separator of ['\u2066', '\u2067', '\u2068', '\u2069']) {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`Authorization: Bearer${separator}secret-token-value`),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes BOM before redacting credentials', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error('Authorization: Bearer\ufeffsecret-token-value'),
|
||||
);
|
||||
|
||||
expect(details).toBe('Authorization: <redacted>');
|
||||
expect(details).not.toContain('secret-token-value');
|
||||
});
|
||||
|
||||
it('sanitizes control characters', () => {
|
||||
expect(extractRememberErrorDetails(new Error('line1\nline2\ttab'))).toBe(
|
||||
'line1 line2 tab',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits empty details after sanitization', () => {
|
||||
expect(extractRememberErrorDetails(new Error('\u200b'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('guards against circular causes', () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic['cause'] = cyclic;
|
||||
|
||||
expect(extractRememberErrorDetails(cyclic)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('limits cause traversal depth', () => {
|
||||
const root: Record<string, unknown> = {};
|
||||
let current = root;
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
const next: Record<string, unknown> = {};
|
||||
current['cause'] = next;
|
||||
current = next;
|
||||
}
|
||||
current['message'] = 'too deep';
|
||||
|
||||
expect(extractRememberErrorDetails(root)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caps long details', () => {
|
||||
const details = extractRememberErrorDetails(new Error('x'.repeat(1100)));
|
||||
|
||||
expect(details).toMatch(/^x+\.{3} \[truncated\]$/);
|
||||
expect(details).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('does not split surrogate pairs when capping long details', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`${'x'.repeat(984)}${'😀'.repeat(100)}`),
|
||||
);
|
||||
|
||||
expect(details).toBe(`${'x'.repeat(984)}... [truncated]`);
|
||||
expect(details).toHaveLength(999);
|
||||
});
|
||||
|
||||
it('keeps the full prefix when the cut point falls before a surrogate pair', () => {
|
||||
const details = extractRememberErrorDetails(
|
||||
new Error(`${'x'.repeat(985)}${'😀'.repeat(100)}`),
|
||||
);
|
||||
|
||||
expect(details).toBe(`${'x'.repeat(985)}... [truncated]`);
|
||||
expect(details).toHaveLength(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRememberErrorStack', () => {
|
||||
it('redacts and caps error stacks before logging', () => {
|
||||
const err = new Error('Authorization: Bearer secret-token-value');
|
||||
err.stack = `Error: Authorization: Bearer secret-token-value\n\tat handler (/workspace/file.ts:1:1)\n${'x'.repeat(1100)}`;
|
||||
|
||||
const stack = extractRememberErrorStack(err);
|
||||
|
||||
expect(stack).toContain('Authorization: <redacted>');
|
||||
expect(stack).toContain('\n\tat handler');
|
||||
expect(stack).not.toContain('secret-token-value');
|
||||
expect(stack).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('preserves CRLF stack line endings before logging', () => {
|
||||
const err = new Error('boom');
|
||||
err.stack = 'Error: boom\r\n\tat handler (/workspace/file.ts:1:1)';
|
||||
|
||||
const stack = extractRememberErrorStack(err);
|
||||
|
||||
expect(stack).toBe('Error: boom\r\n\tat handler (/workspace/file.ts:1:1)');
|
||||
});
|
||||
|
||||
it('normalizes unicode space separators in stacks without collapsing words', () => {
|
||||
const err = new Error('boom');
|
||||
err.stack =
|
||||
'Error: missing column\u202fname\n\tat handler (/workspace/file.ts:1:1)';
|
||||
|
||||
const stack = extractRememberErrorStack(err);
|
||||
|
||||
expect(stack).toBe(
|
||||
'Error: missing column name\n\tat handler (/workspace/file.ts:1:1)',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts stack bearer tokens split by control characters', () => {
|
||||
const err = new Error('Authorization: Bearer secret\x01token-value');
|
||||
err.stack =
|
||||
'Error: Authorization: Bearer secret\x01token-value\n\tat handler (/workspace/file.ts:1:1)';
|
||||
|
||||
const stack = extractRememberErrorStack(err);
|
||||
|
||||
expect(stack).toContain('Authorization: <redacted>');
|
||||
expect(stack).not.toContain('secret');
|
||||
expect(stack).not.toContain('token-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspaceMemoryFailureDiagnostics', () => {
|
||||
it('falls back when detail extraction throws', () => {
|
||||
const extractionErrors: Array<{ target: string; message: string }> = [];
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('detail getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
(target, cause) =>
|
||||
extractionErrors.push({
|
||||
target,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
|
||||
expect(extractionErrors).toEqual([
|
||||
{ target: 'details', message: 'detail getter failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps fallback diagnostics when detail extraction logging throws', () => {
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('detail getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
|
||||
throw new Error('logger failed');
|
||||
});
|
||||
|
||||
expect(diagnostics).toEqual({ debugDetails: '<details unavailable>' });
|
||||
});
|
||||
|
||||
it('falls back when stack extraction throws', () => {
|
||||
const extractionErrors: Array<{ target: string; message: string }> = [];
|
||||
const err = new Proxy(new Error('boom'), {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'stack') {
|
||||
throw new Error('stack getter failed');
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
(target, cause) =>
|
||||
extractionErrors.push({
|
||||
target,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(diagnostics).toEqual({
|
||||
details: 'boom',
|
||||
debugDetails: 'boom',
|
||||
});
|
||||
expect(extractionErrors).toEqual([
|
||||
{ target: 'stack', message: 'stack getter failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps fallback diagnostics when stack extraction logging throws', () => {
|
||||
const err = new Proxy(new Error('boom'), {
|
||||
get(target, property, receiver) {
|
||||
if (property === 'stack') {
|
||||
throw new Error('stack getter failed');
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(err, () => {
|
||||
throw new Error('logger failed');
|
||||
});
|
||||
|
||||
expect(diagnostics).toEqual({
|
||||
details: 'boom',
|
||||
debugDetails: 'boom',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSuppressRememberErrorDetails', () => {
|
||||
it('suppresses details only for configured public errors', () => {
|
||||
expect(
|
||||
shouldSuppressRememberErrorDetails('managed_memory_unavailable'),
|
||||
).toBe(true);
|
||||
expect(shouldSuppressRememberErrorDetails('remember_failed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,71 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { redactLogCredentials } from '@qwen-code/acp-bridge/logRedaction';
|
||||
|
||||
const MAX_REMEMBER_ERROR_DETAILS_CHARS = 1000;
|
||||
const MAX_REMEMBER_ERROR_CAUSE_DEPTH = 50;
|
||||
const REMEMBER_ERROR_FORMAT_RE = /[\p{Cf}]|\p{Variation_Selector}/gu;
|
||||
const REMEMBER_ERROR_SPACE_SEPARATOR_RE =
|
||||
/[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu;
|
||||
/* eslint-disable no-control-regex */
|
||||
const REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE =
|
||||
/[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector}/gu;
|
||||
const REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE =
|
||||
/\b(Bearer|QQBot)(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+(?=[A-Za-z0-9._~+/=-])/giu;
|
||||
const REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE =
|
||||
/\b(Bearer|QQBot)(?:\s|[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+((?:[A-Za-z0-9._~+/=-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9._~+/=-]+)/giu;
|
||||
const REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE =
|
||||
/\b(?:sk-|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[b]-|xox[p]-)(?:[A-Za-z0-9_-]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Za-z0-9_-]+/gu;
|
||||
const REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE =
|
||||
/\b(?:AKIA|ASIA)(?:[A-Z0-9]+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*[A-Z0-9]+/gu;
|
||||
const REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
|
||||
/((?:api[_-]?key|token|secret|password|pwd)[_-]?[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/giu;
|
||||
const REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE =
|
||||
/([A-Z][A-Z0-9]{0,50}(?:_[A-Z0-9]{1,50}){0,10}_(?:KEY|TOKEN|SECRET|PASSWORD)\s*[=:]\s*)((?:\S+(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+)*\S+)/gu;
|
||||
const REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE =
|
||||
/(\b[a-z][a-z0-9+.-]{0,31}:\/\/)([^/@\s]*(?:[\x00-\x1f\x7f-\x9f\p{Cf}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]|\p{Variation_Selector})+[^/@\s]*)@/giu;
|
||||
const REMEMBER_ERROR_CONTROL_RE = /[\x00-\x1f\x7f-\x9f]/g;
|
||||
/* eslint-enable no-control-regex */
|
||||
const DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES = new Set([
|
||||
'managed_memory_unavailable',
|
||||
]);
|
||||
|
||||
export interface WorkspaceMemoryFailureDiagnostics {
|
||||
details?: string;
|
||||
debugDetails: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
type RememberErrorExtractionTarget = 'code' | 'details' | 'stack';
|
||||
type WorkspaceMemoryExtractionLogger = {
|
||||
warn(message: string, context: { extractionError: string }): void;
|
||||
};
|
||||
|
||||
export function createWorkspaceMemoryExtractionErrorLogger(
|
||||
logger: WorkspaceMemoryExtractionLogger,
|
||||
): (target: RememberErrorExtractionTarget, err: unknown) => void {
|
||||
return (target, err) => {
|
||||
logger.warn(`Failed to extract workspace memory error ${target}:`, {
|
||||
extractionError: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function reportRememberErrorExtractionFailure(
|
||||
onExtractionError:
|
||||
| ((target: RememberErrorExtractionTarget, err: unknown) => void)
|
||||
| undefined,
|
||||
target: RememberErrorExtractionTarget,
|
||||
err: unknown,
|
||||
): void {
|
||||
try {
|
||||
onExtractionError?.(target, err);
|
||||
} catch {
|
||||
// Preserve fallback behavior if extraction logging fails.
|
||||
}
|
||||
}
|
||||
|
||||
function errorCodeFromRecord(
|
||||
record: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
|
|
@ -19,19 +84,253 @@ function errorCodeFromRecord(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function rawRememberErrorCode(
|
||||
err: unknown,
|
||||
seen: WeakSet<object>,
|
||||
depth: number,
|
||||
): string | undefined {
|
||||
if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined;
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if (seen.has(err)) return undefined;
|
||||
seen.add(err);
|
||||
|
||||
const record = err as Record<string, unknown>;
|
||||
const direct = errorCodeFromRecord(record);
|
||||
if (direct) return direct;
|
||||
|
||||
const cause = record['cause'];
|
||||
if (cause != null) {
|
||||
return rawRememberErrorCode(cause, seen, depth + 1);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractRememberErrorCode(
|
||||
err: unknown,
|
||||
fallback = 'remember_failed',
|
||||
): string {
|
||||
if (err && typeof err === 'object') {
|
||||
const record = err as Record<string, unknown>;
|
||||
const direct = errorCodeFromRecord(record);
|
||||
if (direct) return direct;
|
||||
const cause = record['cause'];
|
||||
if (cause && typeof cause === 'object') {
|
||||
const causedBy = errorCodeFromRecord(cause as Record<string, unknown>);
|
||||
if (causedBy) return causedBy;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
return rawRememberErrorCode(err, new WeakSet<object>(), 0) ?? fallback;
|
||||
}
|
||||
|
||||
export function workspaceMemoryFailureCode(
|
||||
err: unknown,
|
||||
fallback = 'remember_failed',
|
||||
onExtractionError?: (
|
||||
target: RememberErrorExtractionTarget,
|
||||
err: unknown,
|
||||
) => void,
|
||||
): string {
|
||||
try {
|
||||
return extractRememberErrorCode(err, fallback);
|
||||
} catch (extractionErr) {
|
||||
reportRememberErrorExtractionFailure(
|
||||
onExtractionError,
|
||||
'code',
|
||||
extractionErr,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function detailFromRecord(
|
||||
record: Record<string, unknown>,
|
||||
seen: WeakSet<object>,
|
||||
depth: number,
|
||||
): string | undefined {
|
||||
// Bridge errors carry the best failure reason in `data`; top-level
|
||||
// `message` and `cause` are generic fallbacks.
|
||||
const data = record['data'];
|
||||
if (data && typeof data === 'object') {
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
const details = dataRecord['details'];
|
||||
if (typeof details === 'string' && details.length > 0) return details;
|
||||
const message = dataRecord['message'];
|
||||
if (typeof message === 'string' && message.length > 0) return message;
|
||||
}
|
||||
|
||||
const message = record['message'];
|
||||
if (typeof message === 'string' && message.length > 0) return message;
|
||||
|
||||
if (typeof data === 'string' && data.length > 0) return data;
|
||||
|
||||
const cause = record['cause'];
|
||||
if (cause != null) {
|
||||
return rawRememberErrorDetails(cause, seen, depth + 1);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function rawRememberErrorDetails(
|
||||
err: unknown,
|
||||
seen: WeakSet<object>,
|
||||
depth: number,
|
||||
): string | undefined {
|
||||
if (depth > MAX_REMEMBER_ERROR_CAUSE_DEPTH) return undefined;
|
||||
if (typeof err === 'string' && err.length > 0) return err;
|
||||
if (!err || typeof err !== 'object') return undefined;
|
||||
if (seen.has(err)) return undefined;
|
||||
seen.add(err);
|
||||
return detailFromRecord(err as Record<string, unknown>, seen, depth);
|
||||
}
|
||||
|
||||
function replaceControlChars(details: string): string {
|
||||
return details
|
||||
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
|
||||
.replace(REMEMBER_ERROR_FORMAT_RE, '')
|
||||
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
|
||||
.replace(REMEMBER_ERROR_CONTROL_RE, ' ');
|
||||
}
|
||||
|
||||
function replaceStackControlChars(stack: string): string {
|
||||
return stack
|
||||
.replace(REMEMBER_ERROR_AUTH_SCHEME_INVISIBLE_RE, '$1 ')
|
||||
.replace(REMEMBER_ERROR_FORMAT_RE, '')
|
||||
.replace(REMEMBER_ERROR_SPACE_SEPARATOR_RE, ' ')
|
||||
.replace(REMEMBER_ERROR_CONTROL_RE, (char) =>
|
||||
char === '\n' || char === '\r' || char === '\t' ? char : ' ',
|
||||
);
|
||||
}
|
||||
|
||||
function collapseCredentialSeparators(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
REMEMBER_ERROR_URL_CREDENTIALS_WITH_SEPARATORS_RE,
|
||||
(_match, scheme: string, userinfo: string) =>
|
||||
`${scheme}${userinfo.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}@`,
|
||||
)
|
||||
.replace(
|
||||
REMEMBER_ERROR_AUTH_TOKEN_WITH_SEPARATORS_RE,
|
||||
(_match, scheme: string, token: string) =>
|
||||
`${scheme} ${token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
|
||||
)
|
||||
.replace(REMEMBER_ERROR_BARE_TOKEN_WITH_SEPARATORS_RE, (token) =>
|
||||
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
|
||||
)
|
||||
.replace(REMEMBER_ERROR_AWS_KEY_WITH_SEPARATORS_RE, (token) =>
|
||||
token.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, ''),
|
||||
)
|
||||
.replace(
|
||||
REMEMBER_ERROR_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
|
||||
(_match, prefix: string, value: string) =>
|
||||
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
|
||||
)
|
||||
.replace(
|
||||
REMEMBER_ERROR_ENV_SECRET_ASSIGNMENT_WITH_SEPARATORS_RE,
|
||||
(_match, prefix: string, value: string) =>
|
||||
`${prefix}${value.replace(REMEMBER_ERROR_CREDENTIAL_SEPARATOR_RE, '')}`,
|
||||
);
|
||||
}
|
||||
|
||||
function collapseCredentialSeparatorsByStackLine(stack: string): string {
|
||||
return stack
|
||||
.split(/(\r\n|\n|\r)/)
|
||||
.map((part) =>
|
||||
part === '\r\n' || part === '\n' || part === '\r'
|
||||
? part
|
||||
: collapseCredentialSeparators(part),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isHighSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(codeUnit: number): boolean {
|
||||
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
||||
}
|
||||
|
||||
function truncateBeforeDanglingSurrogate(
|
||||
details: string,
|
||||
cutPoint: number,
|
||||
): number {
|
||||
const beforeCut = details.charCodeAt(cutPoint - 1);
|
||||
const atCut = details.charCodeAt(cutPoint);
|
||||
if (isHighSurrogate(beforeCut) && isLowSurrogate(atCut)) {
|
||||
return cutPoint - 1;
|
||||
}
|
||||
return cutPoint;
|
||||
}
|
||||
|
||||
function capRememberErrorText(redacted: string): string {
|
||||
if (redacted.length <= MAX_REMEMBER_ERROR_DETAILS_CHARS) return redacted;
|
||||
const truncationSuffix = '... [truncated]';
|
||||
const cutPoint = truncateBeforeDanglingSurrogate(
|
||||
redacted,
|
||||
MAX_REMEMBER_ERROR_DETAILS_CHARS - truncationSuffix.length,
|
||||
);
|
||||
return `${redacted.slice(0, cutPoint)}${truncationSuffix}`;
|
||||
}
|
||||
|
||||
function redactAndCapRememberErrorText(normalized: string): string | undefined {
|
||||
const redacted = redactLogCredentials(normalized).trim();
|
||||
if (!redacted) return undefined;
|
||||
return capRememberErrorText(redacted);
|
||||
}
|
||||
|
||||
function sanitizeRememberErrorDetails(details: string): string | undefined {
|
||||
// Keep credential normalization before redaction; auth schemes and token
|
||||
// values may otherwise be split by invisible characters.
|
||||
return redactAndCapRememberErrorText(
|
||||
replaceControlChars(collapseCredentialSeparators(details)),
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeRememberErrorStack(stack: string): string | undefined {
|
||||
return redactAndCapRememberErrorText(
|
||||
replaceStackControlChars(collapseCredentialSeparatorsByStackLine(stack)),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractRememberErrorDetails(err: unknown): string | undefined {
|
||||
const raw = rawRememberErrorDetails(err, new WeakSet<object>(), 0);
|
||||
if (!raw) return undefined;
|
||||
return sanitizeRememberErrorDetails(raw);
|
||||
}
|
||||
|
||||
export function extractRememberErrorStack(err: unknown): string | undefined {
|
||||
if (!(err instanceof Error) || !err.stack) return undefined;
|
||||
return sanitizeRememberErrorStack(err.stack);
|
||||
}
|
||||
|
||||
export function shouldSuppressRememberErrorDetails(code: string): boolean {
|
||||
return DETAIL_SUPPRESSED_REMEMBER_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
export function workspaceMemoryFailureDiagnostics(
|
||||
err: unknown,
|
||||
onExtractionError?: (
|
||||
target: RememberErrorExtractionTarget,
|
||||
err: unknown,
|
||||
) => void,
|
||||
): WorkspaceMemoryFailureDiagnostics {
|
||||
let details: string | undefined;
|
||||
let stack: string | undefined;
|
||||
try {
|
||||
details = extractRememberErrorDetails(err);
|
||||
} catch (extractionErr) {
|
||||
reportRememberErrorExtractionFailure(
|
||||
onExtractionError,
|
||||
'details',
|
||||
extractionErr,
|
||||
);
|
||||
details = undefined;
|
||||
}
|
||||
try {
|
||||
stack = extractRememberErrorStack(err);
|
||||
} catch (extractionErr) {
|
||||
reportRememberErrorExtractionFailure(
|
||||
onExtractionError,
|
||||
'stack',
|
||||
extractionErr,
|
||||
);
|
||||
stack = undefined;
|
||||
}
|
||||
return {
|
||||
...(details ? { details } : {}),
|
||||
debugDetails: details ?? '<details unavailable>',
|
||||
...(stack ? { stack } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ import {
|
|||
} from './workspace-remember.js';
|
||||
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
|
||||
|
||||
const { mockDebugLogger } = vi.hoisted(() => ({
|
||||
mockDebugLogger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
createDebugLogger: () => mockDebugLogger,
|
||||
}));
|
||||
|
||||
type RecordedEvent = Omit<BridgeEvent, 'id' | 'v'>;
|
||||
|
||||
interface Deferred<T> {
|
||||
|
|
@ -211,12 +224,13 @@ function buildApp(
|
|||
tokenConfigured: true,
|
||||
requireAuth: false,
|
||||
},
|
||||
lane = new WorkspaceRememberTaskLane(bridge),
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
mountWorkspaceMemoryRememberRoutes(app, {
|
||||
bridge,
|
||||
lane: new WorkspaceRememberTaskLane(bridge),
|
||||
lane,
|
||||
mutate: createMutationGate(auth),
|
||||
parseClientId: (req, res) => {
|
||||
const raw = req.get('x-qwen-client-id');
|
||||
|
|
@ -863,13 +877,78 @@ describe('workspace memory remember routes', () => {
|
|||
expect(bridge.dreamCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to kind-specific codes when enqueue code extraction throws', async () => {
|
||||
mockDebugLogger.warn.mockClear();
|
||||
const bridge = buildBridgeStub({});
|
||||
const lane = new WorkspaceRememberTaskLane(bridge);
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('code getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
vi.spyOn(lane, 'enqueue').mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
vi.spyOn(lane, 'enqueueForget').mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
vi.spyOn(lane, 'enqueueDream').mockImplementation(() => {
|
||||
throw err;
|
||||
});
|
||||
const app = buildApp(bridge, undefined, lane);
|
||||
|
||||
await request(app)
|
||||
.post('/workspace/memory/remember')
|
||||
.send({ content: 'remember me' })
|
||||
.expect(500)
|
||||
.expect((res) => {
|
||||
expect(res.body).toEqual({
|
||||
error: 'Workspace memory remember failed.',
|
||||
code: 'remember_failed',
|
||||
});
|
||||
});
|
||||
await request(app)
|
||||
.post('/workspace/memory/forget')
|
||||
.send({ query: 'old preference' })
|
||||
.expect(500)
|
||||
.expect((res) => {
|
||||
expect(res.body).toEqual({
|
||||
error: 'Workspace memory forget failed.',
|
||||
code: 'forget_failed',
|
||||
});
|
||||
});
|
||||
await request(app)
|
||||
.post('/workspace/memory/dream')
|
||||
.send({})
|
||||
.expect(500)
|
||||
.expect((res) => {
|
||||
expect(res.body).toEqual({
|
||||
error: 'Workspace memory dream failed.',
|
||||
code: 'dream_failed',
|
||||
});
|
||||
});
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledTimes(3);
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to extract workspace memory error code:',
|
||||
{ extractionError: 'code getter failed' },
|
||||
);
|
||||
});
|
||||
|
||||
it('records bridge failures with stable public error codes', async () => {
|
||||
const bridge = buildBridgeStub({
|
||||
rememberImpl: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({ code: 'remember_path_escape' })
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('agent wrote /tmp/outside'), {
|
||||
code: 'remember_path_escape',
|
||||
}),
|
||||
)
|
||||
.mockRejectedValueOnce({
|
||||
data: { errorKind: 'managed_memory_unavailable' },
|
||||
message: 'internal managed memory config path',
|
||||
})
|
||||
.mockRejectedValueOnce({
|
||||
data: { errorKind: 'remember_timeout' },
|
||||
|
|
@ -890,6 +969,7 @@ describe('workspace memory remember routes', () => {
|
|||
expect(res.body.error).toEqual({
|
||||
code: 'remember_path_escape',
|
||||
message: 'Remember agent touched a path outside managed memory.',
|
||||
details: 'agent wrote /tmp/outside',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -926,7 +1006,96 @@ describe('workspace memory remember routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('logs sanitized details for task-lane failures', async () => {
|
||||
mockDebugLogger.error.mockClear();
|
||||
const bridge = buildBridgeStub({
|
||||
rememberImpl: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error('Authorization: Bearer secret-token-value'),
|
||||
),
|
||||
});
|
||||
const app = buildApp(bridge);
|
||||
|
||||
const post = await request(app)
|
||||
.post('/workspace/memory/remember')
|
||||
.send({ content: 'secret' })
|
||||
.expect(202);
|
||||
await waitFor(() => bridge.rememberCalls.length === 1);
|
||||
await request(app)
|
||||
.get(`/workspace/memory/remember/${post.body.taskId}`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toBe('failed');
|
||||
expect(res.body.error).toEqual({
|
||||
code: 'remember_failed',
|
||||
message: 'Workspace memory remember failed.',
|
||||
details: 'Authorization: <redacted>',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember task failed:',
|
||||
expect.objectContaining({
|
||||
taskId: post.body.taskId,
|
||||
code: 'remember_failed',
|
||||
details: 'Authorization: <redacted>',
|
||||
stack: expect.stringContaining('Authorization: <redacted>'),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(mockDebugLogger.error.mock.calls)).not.toContain(
|
||||
'secret-token-value',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when task-lane error code extraction throws', async () => {
|
||||
mockDebugLogger.error.mockClear();
|
||||
mockDebugLogger.warn.mockClear();
|
||||
const err = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('code getter failed');
|
||||
},
|
||||
},
|
||||
);
|
||||
const bridge = buildBridgeStub({
|
||||
rememberImpl: vi.fn().mockRejectedValue(err),
|
||||
});
|
||||
const app = buildApp(bridge);
|
||||
|
||||
const post = await request(app)
|
||||
.post('/workspace/memory/remember')
|
||||
.send({ content: 'proxy failure' })
|
||||
.expect(202);
|
||||
await waitFor(() => bridge.rememberCalls.length === 1);
|
||||
await request(app)
|
||||
.get(`/workspace/memory/remember/${post.body.taskId}`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.status).toBe('failed');
|
||||
expect(res.body.error).toEqual({
|
||||
code: 'remember_failed',
|
||||
message: 'Workspace memory remember failed.',
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockDebugLogger.warn).toHaveBeenCalledWith(
|
||||
'Failed to extract workspace memory error code:',
|
||||
{ extractionError: 'code getter failed' },
|
||||
);
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory remember task failed:',
|
||||
expect.objectContaining({
|
||||
taskId: post.body.taskId,
|
||||
code: 'remember_failed',
|
||||
details: '<details unavailable>',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records forget and dream failures with kind-specific error codes', async () => {
|
||||
mockDebugLogger.error.mockClear();
|
||||
const bridge = buildBridgeStub({
|
||||
forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')),
|
||||
dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')),
|
||||
|
|
@ -946,8 +1115,18 @@ describe('workspace memory remember routes', () => {
|
|||
expect(res.body.error).toEqual({
|
||||
code: 'forget_failed',
|
||||
message: 'Workspace memory forget failed.',
|
||||
details: 'forget failed',
|
||||
});
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory forget task failed:',
|
||||
expect.objectContaining({
|
||||
taskId: forgetPost.body.taskId,
|
||||
code: 'forget_failed',
|
||||
details: 'forget failed',
|
||||
stack: expect.stringContaining('forget failed'),
|
||||
}),
|
||||
);
|
||||
|
||||
const dreamPost = await request(app)
|
||||
.post('/workspace/memory/dream')
|
||||
|
|
@ -962,7 +1141,17 @@ describe('workspace memory remember routes', () => {
|
|||
expect(res.body.error).toEqual({
|
||||
code: 'dream_failed',
|
||||
message: 'Workspace memory dream failed.',
|
||||
details: 'dream failed',
|
||||
});
|
||||
});
|
||||
expect(mockDebugLogger.error).toHaveBeenCalledWith(
|
||||
'Workspace memory dream task failed:',
|
||||
expect.objectContaining({
|
||||
taskId: dreamPost.body.taskId,
|
||||
code: 'dream_failed',
|
||||
details: 'dream failed',
|
||||
stack: expect.stringContaining('dream failed'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,12 @@ import type {
|
|||
BridgeWorkspaceMemoryRememberContextMode,
|
||||
BridgeWorkspaceMemoryRememberResult,
|
||||
} from './acp-session-bridge.js';
|
||||
import { extractRememberErrorCode } from './workspace-remember-errors.js';
|
||||
import {
|
||||
createWorkspaceMemoryExtractionErrorLogger,
|
||||
shouldSuppressRememberErrorDetails,
|
||||
workspaceMemoryFailureCode,
|
||||
workspaceMemoryFailureDiagnostics,
|
||||
} from './workspace-remember-errors.js';
|
||||
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
|
||||
import {
|
||||
formatWorkspaceMemoryDreamSummary,
|
||||
|
|
@ -43,6 +48,7 @@ interface WorkspaceMemoryTaskBaseSnapshot {
|
|||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -179,6 +185,25 @@ export function publicErrorStatus(code: string): number {
|
|||
return 500;
|
||||
}
|
||||
|
||||
function createTaskError(
|
||||
code: string,
|
||||
kind: WorkspaceMemoryTaskKind,
|
||||
details?: string,
|
||||
): WorkspaceMemoryTaskBaseSnapshot['error'] {
|
||||
const error: WorkspaceMemoryTaskBaseSnapshot['error'] = {
|
||||
code,
|
||||
message: publicErrorMessage(code, kind),
|
||||
};
|
||||
if (shouldSuppressRememberErrorDetails(code)) return error;
|
||||
return {
|
||||
...error,
|
||||
...(details ? { details } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const logWorkspaceMemoryExtractionError =
|
||||
createWorkspaceMemoryExtractionErrorLogger(debugLogger);
|
||||
|
||||
export class WorkspaceRememberTaskLane {
|
||||
private static readonly MAX_TASKS = 1000;
|
||||
private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000;
|
||||
|
|
@ -331,17 +356,23 @@ export class WorkspaceRememberTaskLane {
|
|||
};
|
||||
task.updatedAt = nowIso();
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err);
|
||||
debugLogger.error(
|
||||
'Workspace memory remember task failed:',
|
||||
{ taskId: task.taskId },
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'remember_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
debugLogger.error('Workspace memory remember task failed:', {
|
||||
taskId: task.taskId,
|
||||
code,
|
||||
message: publicErrorMessage(code, task.kind),
|
||||
};
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
task.status = 'failed';
|
||||
task.error = createTaskError(code, task.kind, diagnostics.details);
|
||||
task.updatedAt = nowIso();
|
||||
}
|
||||
try {
|
||||
|
|
@ -397,17 +428,23 @@ export class WorkspaceRememberTaskLane {
|
|||
};
|
||||
task.updatedAt = nowIso();
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
||||
debugLogger.error(
|
||||
'Workspace memory forget task failed:',
|
||||
{ taskId: task.taskId },
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'forget_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
debugLogger.error('Workspace memory forget task failed:', {
|
||||
taskId: task.taskId,
|
||||
code,
|
||||
message: publicErrorMessage(code, task.kind),
|
||||
};
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
task.status = 'failed';
|
||||
task.error = createTaskError(code, task.kind, diagnostics.details);
|
||||
task.updatedAt = nowIso();
|
||||
}
|
||||
try {
|
||||
|
|
@ -459,17 +496,23 @@ export class WorkspaceRememberTaskLane {
|
|||
};
|
||||
task.updatedAt = nowIso();
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err, 'dream_failed');
|
||||
debugLogger.error(
|
||||
'Workspace memory dream task failed:',
|
||||
{ taskId: task.taskId },
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'dream_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
task.status = 'failed';
|
||||
task.error = {
|
||||
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||
err,
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
debugLogger.error('Workspace memory dream task failed:', {
|
||||
taskId: task.taskId,
|
||||
code,
|
||||
message: publicErrorMessage(code, task.kind),
|
||||
};
|
||||
details: diagnostics.debugDetails,
|
||||
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||
});
|
||||
task.status = 'failed';
|
||||
task.error = createTaskError(code, task.kind, diagnostics.details);
|
||||
task.updatedAt = nowIso();
|
||||
}
|
||||
try {
|
||||
|
|
@ -610,7 +653,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
|||
...(originatorClientId ? { originatorClientId } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err);
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'remember_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
res.status(publicErrorStatus(code)).json({
|
||||
error: publicErrorMessage(code, 'remember'),
|
||||
code,
|
||||
|
|
@ -678,7 +725,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
|||
});
|
||||
res.status(202).json(task);
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'forget_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
res.status(publicErrorStatus(code)).json({
|
||||
error: publicErrorMessage(code, 'forget'),
|
||||
code,
|
||||
|
|
@ -723,7 +774,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
|||
});
|
||||
res.status(202).json(task);
|
||||
} catch (err) {
|
||||
const code = extractRememberErrorCode(err, 'dream_failed');
|
||||
const code = workspaceMemoryFailureCode(
|
||||
err,
|
||||
'dream_failed',
|
||||
logWorkspaceMemoryExtractionError,
|
||||
);
|
||||
res.status(publicErrorStatus(code)).json({
|
||||
error: publicErrorMessage(code, 'dream'),
|
||||
code,
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryRememberTask {
|
|||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1116,6 +1117,7 @@ export interface DaemonWorkspaceMemoryForgetTask {
|
|||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1138,6 +1140,7 @@ export interface DaemonWorkspaceMemoryDreamTask {
|
|||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue