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(),
|
mockRunManagedRememberByAgent: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { mockDebugLogger } = vi.hoisted(() => ({
|
||||||
|
mockDebugLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@agentclientprotocol/sdk', () => ({
|
vi.mock('@agentclientprotocol/sdk', () => ({
|
||||||
AgentSideConnection: vi.fn().mockImplementation(() => ({
|
AgentSideConnection: vi.fn().mockImplementation(() => ({
|
||||||
get closed() {
|
get closed() {
|
||||||
|
|
@ -119,12 +128,7 @@ vi.mock('node:stream', async (importOriginal) => {
|
||||||
|
|
||||||
// Mock core dependencies
|
// Mock core dependencies
|
||||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||||
createDebugLogger: () => ({
|
createDebugLogger: () => mockDebugLogger,
|
||||||
debug: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
}),
|
|
||||||
registerAcpEventLoopLagGauge: vi.fn(),
|
registerAcpEventLoopLagGauge: vi.fn(),
|
||||||
startEventLoopLagMonitor: vi.fn(() => ({
|
startEventLoopLagMonitor: vi.fn(() => ({
|
||||||
snapshot: vi.fn(() => ({
|
snapshot: vi.fn(() => ({
|
||||||
|
|
@ -3431,6 +3435,354 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||||
await agentPromise;
|
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 () => {
|
it('runs workspace memory forget without requiring a session', async () => {
|
||||||
const forget = vi.fn().mockResolvedValue({
|
const forget = vi.fn().mockResolvedValue({
|
||||||
systemMessage: 'Forgot 1 entry.',
|
systemMessage: 'Forgot 1 entry.',
|
||||||
|
|
@ -3556,8 +3908,71 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: -32099,
|
code: -32099,
|
||||||
message: 'Workspace memory forget failed',
|
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();
|
mockConnectionState.resolve();
|
||||||
await agentPromise;
|
await agentPromise;
|
||||||
|
|
@ -3600,8 +4015,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: -32099,
|
code: -32099,
|
||||||
message: 'Workspace memory forget timed out',
|
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 {
|
} finally {
|
||||||
timeoutSpy.mockRestore();
|
timeoutSpy.mockRestore();
|
||||||
mockConnectionState.resolve();
|
mockConnectionState.resolve();
|
||||||
|
|
@ -3690,8 +4176,68 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: -32099,
|
code: -32099,
|
||||||
message: 'Workspace memory dream failed',
|
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();
|
mockConnectionState.resolve();
|
||||||
await agentPromise;
|
await agentPromise;
|
||||||
|
|
@ -3731,8 +4277,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
code: -32099,
|
code: -32099,
|
||||||
message: 'Workspace memory dream timed out',
|
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 {
|
} finally {
|
||||||
timeoutSpy.mockRestore();
|
timeoutSpy.mockRestore();
|
||||||
mockConnectionState.resolve();
|
mockConnectionState.resolve();
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,12 @@ import {
|
||||||
buildDisabledSkillNamesProvider,
|
buildDisabledSkillNamesProvider,
|
||||||
loadCliConfig,
|
loadCliConfig,
|
||||||
} from '../config/config.js';
|
} 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 { formatWorkspaceMemoryForgetSummary } from '../serve/workspace-memory-summaries.js';
|
||||||
import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js';
|
import { mapSkillConfigToStatus } from '../serve/workspace-skills-mapping.js';
|
||||||
import { Session, buildAvailableCommandsSnapshot } from './session/Session.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.
|
// Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts.
|
||||||
const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000;
|
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(
|
function parseAcpLocalReadRootsEnv(
|
||||||
raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV],
|
raw = process.env[QWEN_ACP_LOCAL_READ_ROOTS_ENV],
|
||||||
): string[] {
|
): string[] {
|
||||||
|
|
@ -5812,10 +5830,12 @@ class QwenAgent implements Agent {
|
||||||
const childSignal = AbortSignal.timeout(
|
const childSignal = AbortSignal.timeout(
|
||||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
let projectRoot = '<unknown>';
|
||||||
try {
|
try {
|
||||||
|
projectRoot = this.config.getProjectRoot();
|
||||||
const result = await runManagedRememberByAgent({
|
const result = await runManagedRememberByAgent({
|
||||||
config: this.config,
|
config: this.config,
|
||||||
projectRoot: this.config.getProjectRoot(),
|
projectRoot,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
contextMode,
|
contextMode,
|
||||||
abortSignal: childSignal,
|
abortSignal: childSignal,
|
||||||
|
|
@ -5825,15 +5845,36 @@ class QwenAgent implements Agent {
|
||||||
if (err instanceof RequestError) {
|
if (err instanceof RequestError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'remember_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
if (childSignal.aborted) {
|
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(
|
throw new RequestError(
|
||||||
-32099,
|
-32099,
|
||||||
'Workspace memory remember timed out',
|
'Workspace memory remember timed out',
|
||||||
{ errorKind: 'remember_timeout' },
|
workspaceMemoryErrorData(timeoutCode, diagnostics),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const code = extractRememberErrorCode(err);
|
debugLogger.error('Workspace memory remember failed:', {
|
||||||
if (code === 'managed_memory_unavailable') {
|
projectRoot,
|
||||||
|
code,
|
||||||
|
details: diagnostics.debugDetails,
|
||||||
|
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||||
|
});
|
||||||
|
if (shouldSuppressRememberErrorDetails(code)) {
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
-32009,
|
-32009,
|
||||||
'Managed memory is unavailable for this daemon workspace',
|
'Managed memory is unavailable for this daemon workspace',
|
||||||
|
|
@ -5842,12 +5883,8 @@ class QwenAgent implements Agent {
|
||||||
}
|
}
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
-32099,
|
-32099,
|
||||||
err instanceof Error && err.message
|
'Workspace memory remember failed',
|
||||||
? err.message
|
workspaceMemoryErrorData(code, diagnostics),
|
||||||
: 'Workspace memory remember failed',
|
|
||||||
{
|
|
||||||
errorKind: code,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5879,8 +5916,9 @@ class QwenAgent implements Agent {
|
||||||
const childSignal = AbortSignal.timeout(
|
const childSignal = AbortSignal.timeout(
|
||||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
let projectRoot = '<unknown>';
|
||||||
try {
|
try {
|
||||||
const projectRoot = this.config.getProjectRoot();
|
projectRoot = this.config.getProjectRoot();
|
||||||
const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config);
|
const hiddenConfig = createHiddenWorkspaceMemoryConfig(this.config);
|
||||||
const result = await this.config
|
const result = await this.config
|
||||||
.getMemoryManager()
|
.getMemoryManager()
|
||||||
|
|
@ -5900,26 +5938,47 @@ class QwenAgent implements Agent {
|
||||||
if (err instanceof RequestError) {
|
if (err instanceof RequestError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'forget_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
if (childSignal.aborted) {
|
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(
|
throw new RequestError(
|
||||||
-32099,
|
-32099,
|
||||||
'Workspace memory forget timed out',
|
'Workspace memory forget timed out',
|
||||||
{
|
workspaceMemoryErrorData(timeoutCode, diagnostics),
|
||||||
errorKind: 'forget_timeout',
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
debugLogger.error('Workspace memory forget failed:', {
|
||||||
if (code === 'managed_memory_unavailable') {
|
projectRoot,
|
||||||
|
code,
|
||||||
|
details: diagnostics.debugDetails,
|
||||||
|
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||||
|
});
|
||||||
|
if (shouldSuppressRememberErrorDetails(code)) {
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
-32009,
|
-32009,
|
||||||
'Managed memory is unavailable for this daemon workspace',
|
'Managed memory is unavailable for this daemon workspace',
|
||||||
{ errorKind: 'managed_memory_unavailable' },
|
{ errorKind: 'managed_memory_unavailable' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new RequestError(-32099, 'Workspace memory forget failed', {
|
throw new RequestError(
|
||||||
errorKind: code,
|
-32099,
|
||||||
});
|
'Workspace memory forget failed',
|
||||||
|
workspaceMemoryErrorData(code, diagnostics),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: {
|
case SERVE_CONTROL_EXT_METHODS.workspaceMemoryDream: {
|
||||||
|
|
@ -5934,9 +5993,11 @@ class QwenAgent implements Agent {
|
||||||
const childSignal = AbortSignal.timeout(
|
const childSignal = AbortSignal.timeout(
|
||||||
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
let projectRoot = '<unknown>';
|
||||||
try {
|
try {
|
||||||
|
projectRoot = this.config.getProjectRoot();
|
||||||
const result = await runManagedAutoMemoryDream(
|
const result = await runManagedAutoMemoryDream(
|
||||||
this.config.getProjectRoot(),
|
projectRoot,
|
||||||
new Date(),
|
new Date(),
|
||||||
createHiddenWorkspaceMemoryConfig(this.config),
|
createHiddenWorkspaceMemoryConfig(this.config),
|
||||||
childSignal,
|
childSignal,
|
||||||
|
|
@ -5955,22 +6016,47 @@ class QwenAgent implements Agent {
|
||||||
if (err instanceof RequestError) {
|
if (err instanceof RequestError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'dream_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
if (childSignal.aborted) {
|
if (childSignal.aborted) {
|
||||||
throw new RequestError(-32099, 'Workspace memory dream timed out', {
|
const timeoutCode = 'dream_timeout';
|
||||||
errorKind: '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');
|
debugLogger.error('Workspace memory dream failed:', {
|
||||||
if (code === 'managed_memory_unavailable') {
|
projectRoot,
|
||||||
|
code,
|
||||||
|
details: diagnostics.debugDetails,
|
||||||
|
...(diagnostics.stack ? { stack: diagnostics.stack } : {}),
|
||||||
|
});
|
||||||
|
if (shouldSuppressRememberErrorDetails(code)) {
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
-32009,
|
-32009,
|
||||||
'Managed memory is unavailable for this daemon workspace',
|
'Managed memory is unavailable for this daemon workspace',
|
||||||
{ errorKind: 'managed_memory_unavailable' },
|
{ errorKind: 'managed_memory_unavailable' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new RequestError(-32099, 'Workspace memory dream failed', {
|
throw new RequestError(
|
||||||
errorKind: code,
|
-32099,
|
||||||
});
|
'Workspace memory dream failed',
|
||||||
|
workspaceMemoryErrorData(code, diagnostics),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {
|
case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('extractRememberErrorCode', () => {
|
||||||
it('extracts remember error codes from common error shapes', () => {
|
it('extracts remember error codes from common error shapes', () => {
|
||||||
|
|
@ -25,9 +32,553 @@ describe('extractRememberErrorCode', () => {
|
||||||
cause: { code: 'remember_timeout' },
|
cause: { code: 'remember_timeout' },
|
||||||
}),
|
}),
|
||||||
).toBe('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'))).toBe('remember_failed');
|
||||||
expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe(
|
expect(extractRememberErrorCode(new Error('boom'), 'forget_failed')).toBe(
|
||||||
'forget_failed',
|
'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
|
* 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(
|
function errorCodeFromRecord(
|
||||||
record: Record<string, unknown>,
|
record: Record<string, unknown>,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|
@ -19,19 +84,253 @@ function errorCodeFromRecord(
|
||||||
return undefined;
|
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(
|
export function extractRememberErrorCode(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
fallback = 'remember_failed',
|
fallback = 'remember_failed',
|
||||||
): string {
|
): string {
|
||||||
if (err && typeof err === 'object') {
|
return rawRememberErrorCode(err, new WeakSet<object>(), 0) ?? fallback;
|
||||||
const record = err as Record<string, unknown>;
|
}
|
||||||
const direct = errorCodeFromRecord(record);
|
|
||||||
if (direct) return direct;
|
export function workspaceMemoryFailureCode(
|
||||||
const cause = record['cause'];
|
err: unknown,
|
||||||
if (cause && typeof cause === 'object') {
|
fallback = 'remember_failed',
|
||||||
const causedBy = errorCodeFromRecord(cause as Record<string, unknown>);
|
onExtractionError?: (
|
||||||
if (causedBy) return causedBy;
|
target: RememberErrorExtractionTarget,
|
||||||
}
|
err: unknown,
|
||||||
}
|
) => void,
|
||||||
return fallback;
|
): 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';
|
} from './workspace-remember.js';
|
||||||
import { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.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'>;
|
type RecordedEvent = Omit<BridgeEvent, 'id' | 'v'>;
|
||||||
|
|
||||||
interface Deferred<T> {
|
interface Deferred<T> {
|
||||||
|
|
@ -211,12 +224,13 @@ function buildApp(
|
||||||
tokenConfigured: true,
|
tokenConfigured: true,
|
||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
},
|
},
|
||||||
|
lane = new WorkspaceRememberTaskLane(bridge),
|
||||||
) {
|
) {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
mountWorkspaceMemoryRememberRoutes(app, {
|
mountWorkspaceMemoryRememberRoutes(app, {
|
||||||
bridge,
|
bridge,
|
||||||
lane: new WorkspaceRememberTaskLane(bridge),
|
lane,
|
||||||
mutate: createMutationGate(auth),
|
mutate: createMutationGate(auth),
|
||||||
parseClientId: (req, res) => {
|
parseClientId: (req, res) => {
|
||||||
const raw = req.get('x-qwen-client-id');
|
const raw = req.get('x-qwen-client-id');
|
||||||
|
|
@ -863,13 +877,78 @@ describe('workspace memory remember routes', () => {
|
||||||
expect(bridge.dreamCalls).toBe(0);
|
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 () => {
|
it('records bridge failures with stable public error codes', async () => {
|
||||||
const bridge = buildBridgeStub({
|
const bridge = buildBridgeStub({
|
||||||
rememberImpl: vi
|
rememberImpl: vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockRejectedValueOnce({ code: 'remember_path_escape' })
|
.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('agent wrote /tmp/outside'), {
|
||||||
|
code: 'remember_path_escape',
|
||||||
|
}),
|
||||||
|
)
|
||||||
.mockRejectedValueOnce({
|
.mockRejectedValueOnce({
|
||||||
data: { errorKind: 'managed_memory_unavailable' },
|
data: { errorKind: 'managed_memory_unavailable' },
|
||||||
|
message: 'internal managed memory config path',
|
||||||
})
|
})
|
||||||
.mockRejectedValueOnce({
|
.mockRejectedValueOnce({
|
||||||
data: { errorKind: 'remember_timeout' },
|
data: { errorKind: 'remember_timeout' },
|
||||||
|
|
@ -890,6 +969,7 @@ describe('workspace memory remember routes', () => {
|
||||||
expect(res.body.error).toEqual({
|
expect(res.body.error).toEqual({
|
||||||
code: 'remember_path_escape',
|
code: 'remember_path_escape',
|
||||||
message: 'Remember agent touched a path outside managed memory.',
|
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 () => {
|
it('records forget and dream failures with kind-specific error codes', async () => {
|
||||||
|
mockDebugLogger.error.mockClear();
|
||||||
const bridge = buildBridgeStub({
|
const bridge = buildBridgeStub({
|
||||||
forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')),
|
forgetImpl: vi.fn().mockRejectedValue(new Error('forget failed')),
|
||||||
dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')),
|
dreamImpl: vi.fn().mockRejectedValue(new Error('dream failed')),
|
||||||
|
|
@ -946,8 +1115,18 @@ describe('workspace memory remember routes', () => {
|
||||||
expect(res.body.error).toEqual({
|
expect(res.body.error).toEqual({
|
||||||
code: 'forget_failed',
|
code: 'forget_failed',
|
||||||
message: 'Workspace memory 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)
|
const dreamPost = await request(app)
|
||||||
.post('/workspace/memory/dream')
|
.post('/workspace/memory/dream')
|
||||||
|
|
@ -962,7 +1141,17 @@ describe('workspace memory remember routes', () => {
|
||||||
expect(res.body.error).toEqual({
|
expect(res.body.error).toEqual({
|
||||||
code: 'dream_failed',
|
code: 'dream_failed',
|
||||||
message: 'Workspace memory 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,
|
BridgeWorkspaceMemoryRememberContextMode,
|
||||||
BridgeWorkspaceMemoryRememberResult,
|
BridgeWorkspaceMemoryRememberResult,
|
||||||
} from './acp-session-bridge.js';
|
} 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 { MAX_REMEMBER_CONTENT_BYTES } from './workspace-memory-remember-constants.js';
|
||||||
import {
|
import {
|
||||||
formatWorkspaceMemoryDreamSummary,
|
formatWorkspaceMemoryDreamSummary,
|
||||||
|
|
@ -43,6 +48,7 @@ interface WorkspaceMemoryTaskBaseSnapshot {
|
||||||
error?: {
|
error?: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
details?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +185,25 @@ export function publicErrorStatus(code: string): number {
|
||||||
return 500;
|
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 {
|
export class WorkspaceRememberTaskLane {
|
||||||
private static readonly MAX_TASKS = 1000;
|
private static readonly MAX_TASKS = 1000;
|
||||||
private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000;
|
private static readonly TERMINAL_TASK_TTL_MS = 5 * 60_000;
|
||||||
|
|
@ -331,17 +356,23 @@ export class WorkspaceRememberTaskLane {
|
||||||
};
|
};
|
||||||
task.updatedAt = nowIso();
|
task.updatedAt = nowIso();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err);
|
const code = workspaceMemoryFailureCode(
|
||||||
debugLogger.error(
|
|
||||||
'Workspace memory remember task failed:',
|
|
||||||
{ taskId: task.taskId },
|
|
||||||
err,
|
err,
|
||||||
|
'remember_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
);
|
);
|
||||||
task.status = 'failed';
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
task.error = {
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
debugLogger.error('Workspace memory remember task failed:', {
|
||||||
|
taskId: task.taskId,
|
||||||
code,
|
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();
|
task.updatedAt = nowIso();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -397,17 +428,23 @@ export class WorkspaceRememberTaskLane {
|
||||||
};
|
};
|
||||||
task.updatedAt = nowIso();
|
task.updatedAt = nowIso();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
const code = workspaceMemoryFailureCode(
|
||||||
debugLogger.error(
|
|
||||||
'Workspace memory forget task failed:',
|
|
||||||
{ taskId: task.taskId },
|
|
||||||
err,
|
err,
|
||||||
|
'forget_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
);
|
);
|
||||||
task.status = 'failed';
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
task.error = {
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
debugLogger.error('Workspace memory forget task failed:', {
|
||||||
|
taskId: task.taskId,
|
||||||
code,
|
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();
|
task.updatedAt = nowIso();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -459,17 +496,23 @@ export class WorkspaceRememberTaskLane {
|
||||||
};
|
};
|
||||||
task.updatedAt = nowIso();
|
task.updatedAt = nowIso();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err, 'dream_failed');
|
const code = workspaceMemoryFailureCode(
|
||||||
debugLogger.error(
|
|
||||||
'Workspace memory dream task failed:',
|
|
||||||
{ taskId: task.taskId },
|
|
||||||
err,
|
err,
|
||||||
|
'dream_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
);
|
);
|
||||||
task.status = 'failed';
|
const diagnostics = workspaceMemoryFailureDiagnostics(
|
||||||
task.error = {
|
err,
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
|
debugLogger.error('Workspace memory dream task failed:', {
|
||||||
|
taskId: task.taskId,
|
||||||
code,
|
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();
|
task.updatedAt = nowIso();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -610,7 +653,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
||||||
...(originatorClientId ? { originatorClientId } : {}),
|
...(originatorClientId ? { originatorClientId } : {}),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err);
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'remember_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
res.status(publicErrorStatus(code)).json({
|
res.status(publicErrorStatus(code)).json({
|
||||||
error: publicErrorMessage(code, 'remember'),
|
error: publicErrorMessage(code, 'remember'),
|
||||||
code,
|
code,
|
||||||
|
|
@ -678,7 +725,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
||||||
});
|
});
|
||||||
res.status(202).json(task);
|
res.status(202).json(task);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err, 'forget_failed');
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'forget_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
res.status(publicErrorStatus(code)).json({
|
res.status(publicErrorStatus(code)).json({
|
||||||
error: publicErrorMessage(code, 'forget'),
|
error: publicErrorMessage(code, 'forget'),
|
||||||
code,
|
code,
|
||||||
|
|
@ -723,7 +774,11 @@ export function mountWorkspaceMemoryRememberRoutes(
|
||||||
});
|
});
|
||||||
res.status(202).json(task);
|
res.status(202).json(task);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = extractRememberErrorCode(err, 'dream_failed');
|
const code = workspaceMemoryFailureCode(
|
||||||
|
err,
|
||||||
|
'dream_failed',
|
||||||
|
logWorkspaceMemoryExtractionError,
|
||||||
|
);
|
||||||
res.status(publicErrorStatus(code)).json({
|
res.status(publicErrorStatus(code)).json({
|
||||||
error: publicErrorMessage(code, 'dream'),
|
error: publicErrorMessage(code, 'dream'),
|
||||||
code,
|
code,
|
||||||
|
|
|
||||||
|
|
@ -1086,6 +1086,7 @@ export interface DaemonWorkspaceMemoryRememberTask {
|
||||||
error?: {
|
error?: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
details?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1116,6 +1117,7 @@ export interface DaemonWorkspaceMemoryForgetTask {
|
||||||
error?: {
|
error?: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
details?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1138,6 +1140,7 @@ export interface DaemonWorkspaceMemoryDreamTask {
|
||||||
error?: {
|
error?: {
|
||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
details?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue