Compare commits

...

2 Commits

Author SHA1 Message Date
Gabriel Brown 30a17196f5 fix worker forreal
Build and Push Spoon Images / quality (push) Successful in 1m45s
Build and Push Spoon Images / build-images (push) Successful in 7m35s
2026-06-23 21:38:41 -04:00
Gabriel Brown c3d265d428 Fix worker 2026-06-23 20:35:01 -04:00
8 changed files with 412 additions and 68 deletions
+37 -7
View File
@@ -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 =
+32 -2
View File
@@ -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,
+26 -5
View File
@@ -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));
} }
+65 -18
View File
@@ -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]);
+100
View File
@@ -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({