Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30a17196f5 | |||
| c3d265d428 |
@@ -82,8 +82,10 @@ const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
|||||||
record?.toolName ??
|
record?.toolName ??
|
||||||
record?.name ??
|
record?.name ??
|
||||||
record?.function ??
|
record?.function ??
|
||||||
record?.type ??
|
(stringify(record?.type).toLowerCase().includes('exec') ||
|
||||||
record?.command ??
|
record?.command
|
||||||
|
? 'Command'
|
||||||
|
: record?.type) ??
|
||||||
'tool',
|
'tool',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,9 +105,15 @@ const toolOutputFromRecord = (
|
|||||||
) =>
|
) =>
|
||||||
stringify(
|
stringify(
|
||||||
record?.output ??
|
record?.output ??
|
||||||
|
record?.aggregated_output ??
|
||||||
|
record?.stdout ??
|
||||||
|
record?.stderr ??
|
||||||
record?.result ??
|
record?.result ??
|
||||||
record?.content ??
|
record?.content ??
|
||||||
record?.text ??
|
record?.text ??
|
||||||
|
(record?.exit_code !== undefined
|
||||||
|
? `exit code: ${stringify(record.exit_code)}`
|
||||||
|
: undefined) ??
|
||||||
fallback,
|
fallback,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -121,11 +129,17 @@ const recordLooksLikeTool = (
|
|||||||
recordType.includes('tool') ||
|
recordType.includes('tool') ||
|
||||||
recordType.includes('function_call') ||
|
recordType.includes('function_call') ||
|
||||||
recordType.includes('local_shell_call') ||
|
recordType.includes('local_shell_call') ||
|
||||||
|
recordType.includes('exec_command') ||
|
||||||
|
recordType.includes('command') ||
|
||||||
recordType.includes('mcp') ||
|
recordType.includes('mcp') ||
|
||||||
Boolean(record?.tool ?? record?.tool_name ?? record?.name)
|
Boolean(record?.tool ?? record?.tool_name ?? record?.name ?? record?.command)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isCodexConfigWarning = (message: string) =>
|
||||||
|
message.includes('`[features].codex_hooks` is deprecated') ||
|
||||||
|
message.includes('Use `[features].hooks` instead');
|
||||||
|
|
||||||
export const normalizeCodexJsonLine = (
|
export const normalizeCodexJsonLine = (
|
||||||
line: string,
|
line: string,
|
||||||
): NormalizedAgentEvent[] => {
|
): NormalizedAgentEvent[] => {
|
||||||
@@ -139,9 +153,16 @@ export const normalizeCodexJsonLine = (
|
|||||||
const event = asRecord(parsed);
|
const event = asRecord(parsed);
|
||||||
if (!event) return [];
|
if (!event) return [];
|
||||||
const type = stringify(event.type ?? event.event);
|
const type = stringify(event.type ?? event.event);
|
||||||
const id = event.id ?? event.session_id ?? event.sessionId;
|
const id =
|
||||||
|
event.id ??
|
||||||
|
event.session_id ??
|
||||||
|
event.sessionId ??
|
||||||
|
event.thread_id ??
|
||||||
|
event.threadId;
|
||||||
const sessionId =
|
const sessionId =
|
||||||
typeof id === 'string' && type.toLowerCase().includes('session')
|
typeof id === 'string' &&
|
||||||
|
(type.toLowerCase().includes('session') ||
|
||||||
|
type.toLowerCase().includes('thread.started'))
|
||||||
? id
|
? id
|
||||||
: undefined;
|
: undefined;
|
||||||
const events: NormalizedAgentEvent[] = sessionId
|
const events: NormalizedAgentEvent[] = sessionId
|
||||||
@@ -198,13 +219,22 @@ export const normalizeCodexJsonLine = (
|
|||||||
itemType.includes('message') ||
|
itemType.includes('message') ||
|
||||||
itemType.includes('agent_message'))
|
itemType.includes('agent_message'))
|
||||||
) {
|
) {
|
||||||
events.push({ kind: 'assistant_delta', content: text });
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: itemType.includes('agent_message') ? `${text.trim()}\n\n` : text,
|
||||||
|
externalMessageId: stringify(item?.id ?? event.id),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const error = event.error ?? item?.error;
|
const error = event.error ?? item?.error;
|
||||||
if (error || itemType === 'error') {
|
if (error || itemType === 'error') {
|
||||||
|
const message = stringify(error ?? item?.message ?? event.message);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
events.push({
|
events.push({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: stringify(error ?? item?.message ?? event.message),
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const command =
|
const command =
|
||||||
|
|||||||
@@ -126,12 +126,42 @@ export const getDiff = async (
|
|||||||
export const getWorktreeDiff = async (
|
export const getWorktreeDiff = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
redact: (value: string) => string,
|
redact: (value: string) => string,
|
||||||
) =>
|
) => {
|
||||||
await run('git', ['diff', '--', '.'], {
|
const trackedDiff = await run('git', ['diff', '--', '.'], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
});
|
});
|
||||||
|
const untracked = await run(
|
||||||
|
'git',
|
||||||
|
['ls-files', '--others', '--exclude-standard'],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const untrackedDiffs: string[] = [];
|
||||||
|
for (const filePath of untracked.output.split('\n').filter(Boolean)) {
|
||||||
|
const diff = await run(
|
||||||
|
'git',
|
||||||
|
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (diff.output.trim()) untrackedDiffs.push(diff.output);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode:
|
||||||
|
trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||||
|
output: [trackedDiff.output, ...untrackedDiffs]
|
||||||
|
.filter((part) => part.trim())
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getStatus = async (
|
export const getStatus = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const runInJobContainer = async (args: {
|
|||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -102,7 +103,7 @@ export const startWorkspaceContainer = async (args: {
|
|||||||
env.jobImage,
|
env.jobImage,
|
||||||
...(args.command ?? ['sleep', 'infinity']),
|
...(args.command ?? ['sleep', 'infinity']),
|
||||||
],
|
],
|
||||||
{ all: true },
|
{ all: true, stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
containerId: result.stdout.trim(),
|
containerId: result.stdout.trim(),
|
||||||
@@ -117,7 +118,7 @@ const getPublishedPort = async (containerName: string, containerPort: number) =>
|
|||||||
const result = await execa(
|
const result = await execa(
|
||||||
containerRuntime(),
|
containerRuntime(),
|
||||||
['port', containerName, `${containerPort}/tcp`],
|
['port', containerName, `${containerPort}/tcp`],
|
||||||
{ all: true, reject: false },
|
{ all: true, reject: false, stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
const output = result.all.trim();
|
const output = result.all.trim();
|
||||||
const match = /:(\d+)\s*$/.exec(output);
|
const match = /:(\d+)\s*$/.exec(output);
|
||||||
@@ -147,6 +148,7 @@ export const execInWorkspaceContainer = async (args: {
|
|||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -186,12 +188,14 @@ export const streamInJobContainer = async (args: {
|
|||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let stdoutBuffer = '';
|
let stdoutBuffer = '';
|
||||||
let stderrBuffer = '';
|
let stderrBuffer = '';
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
|
let lineHandlers = Promise.resolve();
|
||||||
const consume = async (
|
const consume = async (
|
||||||
chunk: Buffer,
|
chunk: Buffer,
|
||||||
source: 'stdout' | 'stderr',
|
source: 'stdout' | 'stderr',
|
||||||
@@ -210,12 +214,29 @@ export const streamInJobContainer = async (args: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||||
void consume(chunk, 'stdout', args.onStdoutLine);
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stdout', args.onStdoutLine),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
subprocess.stderr.on('data', (chunk: Buffer) => {
|
subprocess.stderr.on('data', (chunk: Buffer) => {
|
||||||
void consume(chunk, 'stderr', args.onStderrLine);
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stderr', args.onStderrLine),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const result = await subprocess;
|
let result: Awaited<typeof subprocess>;
|
||||||
|
try {
|
||||||
|
result = await subprocess;
|
||||||
|
} catch (error) {
|
||||||
|
await lineHandlers;
|
||||||
|
const outputText = output.join('');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Container command failed.';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: args.redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await lineHandlers;
|
||||||
if (stdoutBuffer && args.onStdoutLine) {
|
if (stdoutBuffer && args.onStdoutLine) {
|
||||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
await args.onStdoutLine(args.redact(stdoutBuffer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ type ActiveWorkspace = {
|
|||||||
codexSessionId?: string;
|
codexSessionId?: string;
|
||||||
agentTurnActive?: boolean;
|
agentTurnActive?: boolean;
|
||||||
resolveTurn?: () => void;
|
resolveTurn?: () => void;
|
||||||
|
lastRecordedDiffSignature?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FileTreeNode = {
|
type FileTreeNode = {
|
||||||
@@ -430,6 +431,9 @@ const codexModel = (claim: Claim) => {
|
|||||||
return model.includes('/') ? model.split('/').at(-1) ?? model : model;
|
return model.includes('/') ? model.split('/').at(-1) ?? model : model;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const codexModelArgs = (claim: Claim) =>
|
||||||
|
isCodexLoginProfile(claim) ? [] : ['--model', codexModel(claim)];
|
||||||
|
|
||||||
const writeJsonFile = async (filePath: string, content: string) => {
|
const writeJsonFile = async (filePath: string, content: string) => {
|
||||||
let normalized = content.trim();
|
let normalized = content.trim();
|
||||||
try {
|
try {
|
||||||
@@ -693,20 +697,26 @@ const runCodexTurn = async (args: {
|
|||||||
codexSessionId: workspace.codexSessionId,
|
codexSessionId: workspace.codexSessionId,
|
||||||
});
|
});
|
||||||
const command = workspace.codexSessionId
|
const command = workspace.codexSessionId
|
||||||
? commandToShell(
|
? [
|
||||||
`codex exec resume --json --model ${quoteShell(
|
'codex',
|
||||||
codexModel(workspace.claim),
|
'exec',
|
||||||
)} --dangerously-bypass-approvals-and-sandbox ${quoteShell(
|
'resume',
|
||||||
|
'--json',
|
||||||
|
...codexModelArgs(workspace.claim),
|
||||||
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
workspace.codexSessionId,
|
workspace.codexSessionId,
|
||||||
)} ${quoteShell(prompt)}`,
|
prompt,
|
||||||
)
|
]
|
||||||
: commandToShell(
|
: [
|
||||||
`codex exec --json --model ${quoteShell(
|
'codex',
|
||||||
codexModel(workspace.claim),
|
'exec',
|
||||||
)} --dangerously-bypass-approvals-and-sandbox --cd ${quoteShell(
|
'--json',
|
||||||
|
...codexModelArgs(workspace.claim),
|
||||||
|
'--dangerously-bypass-approvals-and-sandbox',
|
||||||
|
'--cd',
|
||||||
codexContainerRepo,
|
codexContainerRepo,
|
||||||
)} ${quoteShell(prompt)}`,
|
prompt,
|
||||||
);
|
];
|
||||||
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
|
||||||
const secretEnv = Object.fromEntries(
|
const secretEnv = Object.fromEntries(
|
||||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
@@ -731,12 +741,17 @@ const runCodexTurn = async (args: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStderrLine: async (line) => {
|
onStderrLine: async (line) => {
|
||||||
if (line.trim()) {
|
const trimmed = line.trim();
|
||||||
|
if (
|
||||||
|
trimmed &&
|
||||||
|
trimmed !== 'Reading additional input from stdin...' &&
|
||||||
|
!trimmed.includes('`[features].codex_hooks` is deprecated')
|
||||||
|
) {
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
workspace.claim.job._id,
|
workspace.claim.job._id,
|
||||||
'debug',
|
'info',
|
||||||
'plan',
|
'plan',
|
||||||
truncate(line, 10_000),
|
truncate(trimmed, 10_000),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1031,6 +1046,9 @@ const recordChangedFiles = async (
|
|||||||
workspace.repoDir,
|
workspace.repoDir,
|
||||||
workspace.redact,
|
workspace.redact,
|
||||||
);
|
);
|
||||||
|
const signature = JSON.stringify({ diff, changes });
|
||||||
|
if (signature === workspace.lastRecordedDiffSignature) return;
|
||||||
|
workspace.lastRecordedDiffSignature = signature;
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
await recordWorkspaceChange({
|
await recordWorkspaceChange({
|
||||||
jobId: workspace.claim.job._id,
|
jobId: workspace.claim.job._id,
|
||||||
@@ -1232,7 +1250,9 @@ const runClaim = async (claim: Claim) => {
|
|||||||
});
|
});
|
||||||
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
||||||
|
|
||||||
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
await sendWorkspaceMessage(jobId, systemPromptForJob(claim), {
|
||||||
|
recordUserMessage: false,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
@@ -1425,23 +1445,30 @@ export const replyToInteraction = async (
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
export const sendWorkspaceMessage = async (
|
||||||
|
jobId: string,
|
||||||
|
prompt: string,
|
||||||
|
options: { recordUserMessage?: boolean } = {},
|
||||||
|
) => {
|
||||||
const workspace = resolveWorkspace(jobId);
|
const workspace = resolveWorkspace(jobId);
|
||||||
const { claim, redact } = workspace;
|
const { claim, redact } = workspace;
|
||||||
if (workspace.agentTurnActive) {
|
if (workspace.agentTurnActive) {
|
||||||
throw new Error('Wait for the current agent turn to finish or abort it.');
|
throw new Error('Wait for the current agent turn to finish or abort it.');
|
||||||
}
|
}
|
||||||
|
if (options.recordUserMessage ?? true) {
|
||||||
await appendMessage({
|
await appendMessage({
|
||||||
jobId: claim.job._id,
|
jobId: claim.job._id,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
content: prompt,
|
content: prompt,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
||||||
|
|
||||||
|
let assistantMessageId: Id<'agentJobMessages'> | undefined;
|
||||||
try {
|
try {
|
||||||
workspace.agentTurnActive = true;
|
workspace.agentTurnActive = true;
|
||||||
const assistantMessageId = await appendMessage({
|
assistantMessageId = await appendMessage({
|
||||||
jobId: claim.job._id,
|
jobId: claim.job._id,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
status: 'streaming',
|
status: 'streaming',
|
||||||
@@ -1449,6 +1476,12 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
|||||||
});
|
});
|
||||||
const assistantContent = { value: '' };
|
const assistantContent = { value: '' };
|
||||||
if (isCodexLoginProfile(claim)) {
|
if (isCodexLoginProfile(claim)) {
|
||||||
|
await appendEvent(
|
||||||
|
claim.job._id,
|
||||||
|
'info',
|
||||||
|
'plan',
|
||||||
|
'Starting Codex CLI turn with the configured login profile.',
|
||||||
|
);
|
||||||
await runCodexTurn({
|
await runCodexTurn({
|
||||||
workspace,
|
workspace,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -1456,6 +1489,12 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
|||||||
assistantContent,
|
assistantContent,
|
||||||
});
|
});
|
||||||
} else if (env.runtime === 'docker') {
|
} else if (env.runtime === 'docker') {
|
||||||
|
await appendEvent(
|
||||||
|
claim.job._id,
|
||||||
|
'info',
|
||||||
|
'plan',
|
||||||
|
'Starting OpenCode server turn with the configured API provider.',
|
||||||
|
);
|
||||||
await runOpenCodeTurn({
|
await runOpenCodeTurn({
|
||||||
workspace,
|
workspace,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -1532,12 +1571,20 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
|||||||
'cleanup',
|
'cleanup',
|
||||||
truncate(redact(message), 20_000),
|
truncate(redact(message), 20_000),
|
||||||
);
|
);
|
||||||
|
if (assistantMessageId) {
|
||||||
|
await updateMessage({
|
||||||
|
messageId: assistantMessageId,
|
||||||
|
status: 'failed',
|
||||||
|
content: truncate(redact(message), 40_000),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await appendMessage({
|
await appendMessage({
|
||||||
jobId: claim.job._id,
|
jobId: claim.job._id,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
content: truncate(redact(message), 40_000),
|
content: truncate(redact(message), 40_000),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,36 @@ describe('agent event normalization', () => {
|
|||||||
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex CLI thread lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'thread.started',
|
||||||
|
thread_id: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'session',
|
||||||
|
sessionId: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.started',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'status', status: 'turn.started' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.completed',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizes Codex command and file events', () => {
|
test('normalizes Codex command and file events', () => {
|
||||||
expect(
|
expect(
|
||||||
normalizeCodexJsonLine(
|
normalizeCodexJsonLine(
|
||||||
@@ -65,7 +95,8 @@ describe('agent event normalization', () => {
|
|||||||
),
|
),
|
||||||
).toContainEqual({
|
).toContainEqual({
|
||||||
kind: 'assistant_delta',
|
kind: 'assistant_delta',
|
||||||
content: 'I updated the auth provider.',
|
content: 'I updated the auth provider.\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -95,6 +126,24 @@ describe('agent event normalization', () => {
|
|||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: '{\n "message": "request failed"\n}',
|
message: '{\n "message": "request failed"\n}',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-warning',
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes Codex tool item lifecycle events', () => {
|
test('normalizes Codex tool item lifecycle events', () => {
|
||||||
@@ -111,7 +160,7 @@ describe('agent event normalization', () => {
|
|||||||
),
|
),
|
||||||
).toContainEqual({
|
).toContainEqual({
|
||||||
kind: 'tool_started',
|
kind: 'tool_started',
|
||||||
name: 'local_shell_call',
|
name: 'Command',
|
||||||
input: 'bash -lc rg Authentik',
|
input: 'bash -lc rg Authentik',
|
||||||
externalMessageId: 'tool-1',
|
externalMessageId: 'tool-1',
|
||||||
});
|
});
|
||||||
@@ -130,10 +179,30 @@ describe('agent event normalization', () => {
|
|||||||
),
|
),
|
||||||
).toContainEqual({
|
).toContainEqual({
|
||||||
kind: 'tool_completed',
|
kind: 'tool_completed',
|
||||||
name: 'local_shell_call',
|
name: 'Command',
|
||||||
output: 'apps/web/auth.ts',
|
output: 'apps/web/auth.ts',
|
||||||
externalMessageId: 'tool-1',
|
externalMessageId: 'tool-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-2',
|
||||||
|
type: 'exec_command',
|
||||||
|
command: 'cat package.json',
|
||||||
|
aggregated_output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
externalMessageId: 'tool-2',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
|||||||
const filters: { value: ActivityFilter; label: string }[] = [
|
const filters: { value: ActivityFilter; label: string }[] = [
|
||||||
{ value: 'all', label: 'All' },
|
{ value: 'all', label: 'All' },
|
||||||
{ value: 'chat', label: 'Chat' },
|
{ value: 'chat', label: 'Chat' },
|
||||||
{ value: 'activity', label: 'Activity' },
|
{ value: 'activity', label: 'Tools' },
|
||||||
{ value: 'files', label: 'Files' },
|
{ value: 'files', label: 'Files' },
|
||||||
{ value: 'errors', label: 'Errors' },
|
{ value: 'errors', label: 'Errors' },
|
||||||
];
|
];
|
||||||
@@ -68,22 +68,42 @@ export const AgentThread = ({
|
|||||||
const [replying, setReplying] = useState<string>();
|
const [replying, setReplying] = useState<string>();
|
||||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const failedMessages = useMemo(
|
const chatMessages = useMemo(
|
||||||
() => messages.filter((message) => message.status === 'failed'),
|
() =>
|
||||||
|
messages.filter((message) => {
|
||||||
|
if (message.role === 'system') return false;
|
||||||
|
if (message.role === 'tool') return false;
|
||||||
|
if (message.role === 'assistant' && !message.content.trim()) {
|
||||||
|
return message.status === 'streaming' && agentTurnActive;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[agentTurnActive, messages],
|
||||||
|
);
|
||||||
|
const toolMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
messages.filter(
|
||||||
|
(message) => message.role === 'tool' && message.content.trim(),
|
||||||
|
),
|
||||||
[messages],
|
[messages],
|
||||||
);
|
);
|
||||||
|
const failedMessages = useMemo(
|
||||||
|
() => chatMessages.filter((message) => message.status === 'failed'),
|
||||||
|
[chatMessages],
|
||||||
|
);
|
||||||
|
const errorEvents = useMemo(
|
||||||
|
() => events.filter((event) => event.level === 'error'),
|
||||||
|
[events],
|
||||||
|
);
|
||||||
const visibleMessages =
|
const visibleMessages =
|
||||||
filter === 'activity' || filter === 'files' || filter === 'errors'
|
filter === 'activity' || filter === 'files' || filter === 'errors'
|
||||||
? filter === 'errors'
|
? filter === 'errors'
|
||||||
? failedMessages
|
? failedMessages
|
||||||
: []
|
: []
|
||||||
: messages;
|
: chatMessages;
|
||||||
const visibleEvents =
|
const visibleToolMessages =
|
||||||
filter === 'chat' || filter === 'files'
|
filter === 'all' || filter === 'activity' ? toolMessages : [];
|
||||||
? []
|
const visibleEvents = filter === 'errors' ? errorEvents : [];
|
||||||
: filter === 'errors'
|
|
||||||
? events.filter((event) => event.level === 'error')
|
|
||||||
: events;
|
|
||||||
const visibleChanges =
|
const visibleChanges =
|
||||||
filter === 'chat' || filter === 'activity' || filter === 'errors'
|
filter === 'chat' || filter === 'activity' || filter === 'errors'
|
||||||
? []
|
? []
|
||||||
@@ -260,15 +280,19 @@ export const AgentThread = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||||
<span className='font-medium capitalize'>{message.role}</span>
|
<span className='font-medium'>
|
||||||
|
{message.role === 'assistant' ? 'Agent' : 'You'}
|
||||||
|
</span>
|
||||||
|
{message.status === 'failed' || message.status === 'streaming' ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
message.status === 'failed' ? 'destructive' : 'outline'
|
message.status === 'failed' ? 'destructive' : 'outline'
|
||||||
}
|
}
|
||||||
className='capitalize'
|
className='capitalize'
|
||||||
>
|
>
|
||||||
{message.status}
|
{message.status === 'streaming' ? 'Working' : 'Failed'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className='whitespace-pre-wrap'>
|
<p className='whitespace-pre-wrap'>
|
||||||
{message.content ||
|
{message.content ||
|
||||||
@@ -276,6 +300,23 @@ export const AgentThread = ({
|
|||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{visibleToolMessages.map((message) => (
|
||||||
|
<article
|
||||||
|
key={message._id}
|
||||||
|
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||||
|
>
|
||||||
|
<div className='mb-2 flex items-center gap-2'>
|
||||||
|
<Terminal className='text-primary size-4' />
|
||||||
|
<span className='font-medium'>Tool</span>
|
||||||
|
{message.status === 'streaming' ? (
|
||||||
|
<Badge variant='outline'>Running</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<pre className='text-muted-foreground max-h-56 overflow-auto text-xs whitespace-pre-wrap'>
|
||||||
|
{message.content}
|
||||||
|
</pre>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
{visibleChanges.map((change) => (
|
{visibleChanges.map((change) => (
|
||||||
<article
|
<article
|
||||||
key={change._id}
|
key={change._id}
|
||||||
@@ -356,6 +397,7 @@ export const AgentThread = ({
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{visibleMessages.length === 0 &&
|
{visibleMessages.length === 0 &&
|
||||||
|
visibleToolMessages.length === 0 &&
|
||||||
visibleEvents.length === 0 &&
|
visibleEvents.length === 0 &&
|
||||||
visibleChanges.length === 0 &&
|
visibleChanges.length === 0 &&
|
||||||
(filter !== 'chat' || interactions.length === 0) ? (
|
(filter !== 'chat' || interactions.length === 0) ? (
|
||||||
|
|||||||
@@ -114,9 +114,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
setAgentTurnActive(false);
|
setAgentTurnActive(false);
|
||||||
|
const body = await response.text();
|
||||||
|
if (body.includes('workspace is not active')) {
|
||||||
|
setWorkspaceError(body);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { active?: boolean };
|
const data = (await response.json()) as { active?: boolean };
|
||||||
|
setWorkspaceError(undefined);
|
||||||
setAgentTurnActive(Boolean(data.active));
|
setAgentTurnActive(Boolean(data.active));
|
||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,106 @@ describe('component test harness', () => {
|
|||||||
expect(onOpenFile).toHaveBeenCalledWith('apps/web/auth.ts');
|
expect(onOpenFile).toHaveBeenCalledWith('apps/web/auth.ts');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the workspace thread focused on user, agent, and tool content', () => {
|
||||||
|
render(
|
||||||
|
<AgentThread
|
||||||
|
jobId='job-1'
|
||||||
|
messages={[
|
||||||
|
{
|
||||||
|
_id: 'message-system',
|
||||||
|
_creationTime: 1,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'system',
|
||||||
|
content: 'Workspace is ready.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-empty-assistant',
|
||||||
|
_creationTime: 2,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-user',
|
||||||
|
_creationTime: 3,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Use Authentik as the only provider.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 3,
|
||||||
|
updatedAt: 3,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-assistant',
|
||||||
|
_creationTime: 4,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'I found the Auth.js provider configuration.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 4,
|
||||||
|
updatedAt: 4,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-tool',
|
||||||
|
_creationTime: 5,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'tool',
|
||||||
|
content: 'rg Authentik',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 5,
|
||||||
|
updatedAt: 5,
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
events={[
|
||||||
|
{
|
||||||
|
_id: 'event-info',
|
||||||
|
_creationTime: 1,
|
||||||
|
jobId: 'job-1',
|
||||||
|
level: 'info',
|
||||||
|
phase: 'plan',
|
||||||
|
message: 'Sending message to agent.',
|
||||||
|
createdAt: 1,
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
interactions={[]}
|
||||||
|
workspaceChanges={[]}
|
||||||
|
disabled={false}
|
||||||
|
agentTurnActive={false}
|
||||||
|
onOpenFile={vi.fn()}
|
||||||
|
onOpenDiff={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Workspace is ready.')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Sending message to agent.'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Assistant')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Use Authentik as the only provider.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('I found the Auth.js provider configuration.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('rg Authentik')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders thread workspaces on the canonical thread route', () => {
|
it('renders thread workspaces on the canonical thread route', () => {
|
||||||
mockUseParams.mockReturnValue({ threadId: 'thread-1' });
|
mockUseParams.mockReturnValue({ threadId: 'thread-1' });
|
||||||
mockUseQuery.mockReturnValue({
|
mockUseQuery.mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user