Fix worker

This commit is contained in:
Gabriel Brown
2026-06-23 20:35:01 -04:00
parent 5567a4be95
commit c3d265d428
7 changed files with 249 additions and 35 deletions
+9 -2
View File
@@ -139,9 +139,16 @@ export const normalizeCodexJsonLine = (
const event = asRecord(parsed);
if (!event) return [];
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 =
typeof id === 'string' && type.toLowerCase().includes('session')
typeof id === 'string' &&
(type.toLowerCase().includes('session') ||
type.toLowerCase().includes('thread.started'))
? id
: undefined;
const events: NormalizedAgentEvent[] = sessionId
+8 -2
View File
@@ -192,6 +192,7 @@ export const streamInJobContainer = async (args: {
let stdoutBuffer = '';
let stderrBuffer = '';
const output: string[] = [];
let lineHandlers = Promise.resolve();
const consume = async (
chunk: Buffer,
source: 'stdout' | 'stderr',
@@ -210,12 +211,17 @@ export const streamInJobContainer = async (args: {
}
};
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) => {
void consume(chunk, 'stderr', args.onStderrLine);
lineHandlers = lineHandlers.then(() =>
consume(chunk, 'stderr', args.onStderrLine),
);
});
const result = await subprocess;
await lineHandlers;
if (stdoutBuffer && args.onStdoutLine) {
await args.onStdoutLine(args.redact(stdoutBuffer));
}
+36 -12
View File
@@ -430,6 +430,9 @@ const codexModel = (claim: Claim) => {
return model.includes('/') ? model.split('/').at(-1) ?? model : model;
};
const codexModelFlag = (claim: Claim) =>
isCodexLoginProfile(claim) ? '' : ` --model ${quoteShell(codexModel(claim))}`;
const writeJsonFile = async (filePath: string, content: string) => {
let normalized = content.trim();
try {
@@ -694,15 +697,15 @@ const runCodexTurn = async (args: {
});
const command = workspace.codexSessionId
? commandToShell(
`codex exec resume --json --model ${quoteShell(
codexModel(workspace.claim),
`codex exec resume --json${codexModelFlag(
workspace.claim,
)} --dangerously-bypass-approvals-and-sandbox ${quoteShell(
workspace.codexSessionId,
)} ${quoteShell(prompt)}`,
)
: commandToShell(
`codex exec --json --model ${quoteShell(
codexModel(workspace.claim),
`codex exec --json${codexModelFlag(
workspace.claim,
)} --dangerously-bypass-approvals-and-sandbox --cd ${quoteShell(
codexContainerRepo,
)} ${quoteShell(prompt)}`,
@@ -734,7 +737,7 @@ const runCodexTurn = async (args: {
if (line.trim()) {
await appendEvent(
workspace.claim.job._id,
'debug',
'info',
'plan',
truncate(line, 10_000),
);
@@ -1439,9 +1442,10 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
});
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
let assistantMessageId: Id<'agentJobMessages'> | undefined;
try {
workspace.agentTurnActive = true;
const assistantMessageId = await appendMessage({
assistantMessageId = await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'streaming',
@@ -1449,6 +1453,12 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
});
const assistantContent = { value: '' };
if (isCodexLoginProfile(claim)) {
await appendEvent(
claim.job._id,
'info',
'plan',
'Starting Codex CLI turn with the configured login profile.',
);
await runCodexTurn({
workspace,
prompt,
@@ -1456,6 +1466,12 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
assistantContent,
});
} else if (env.runtime === 'docker') {
await appendEvent(
claim.job._id,
'info',
'plan',
'Starting OpenCode server turn with the configured API provider.',
);
await runOpenCodeTurn({
workspace,
prompt,
@@ -1532,12 +1548,20 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
'cleanup',
truncate(redact(message), 20_000),
);
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'failed',
content: truncate(redact(message), 40_000),
});
if (assistantMessageId) {
await updateMessage({
messageId: assistantMessageId,
status: 'failed',
content: truncate(redact(message), 40_000),
});
} else {
await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'failed',
content: truncate(redact(message), 40_000),
});
}
throw error;
}
};
@@ -26,6 +26,36 @@ describe('agent event normalization', () => {
).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', () => {
expect(
normalizeCodexJsonLine(
@@ -21,7 +21,7 @@ type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
const filters: { value: ActivityFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'chat', label: 'Chat' },
{ value: 'activity', label: 'Activity' },
{ value: 'activity', label: 'Tools' },
{ value: 'files', label: 'Files' },
{ value: 'errors', label: 'Errors' },
];
@@ -68,22 +68,42 @@ export const AgentThread = ({
const [replying, setReplying] = useState<string>();
const [filter, setFilter] = useState<ActivityFilter>('all');
const scrollRef = useRef<HTMLDivElement>(null);
const failedMessages = useMemo(
() => messages.filter((message) => message.status === 'failed'),
const chatMessages = useMemo(
() =>
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],
);
const failedMessages = useMemo(
() => chatMessages.filter((message) => message.status === 'failed'),
[chatMessages],
);
const errorEvents = useMemo(
() => events.filter((event) => event.level === 'error'),
[events],
);
const visibleMessages =
filter === 'activity' || filter === 'files' || filter === 'errors'
? filter === 'errors'
? failedMessages
: []
: messages;
const visibleEvents =
filter === 'chat' || filter === 'files'
? []
: filter === 'errors'
? events.filter((event) => event.level === 'error')
: events;
: chatMessages;
const visibleToolMessages =
filter === 'all' || filter === 'activity' ? toolMessages : [];
const visibleEvents = filter === 'errors' ? errorEvents : [];
const visibleChanges =
filter === 'chat' || filter === 'activity' || filter === 'errors'
? []
@@ -260,15 +280,19 @@ export const AgentThread = ({
}
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status}
</Badge>
<span className='font-medium'>
{message.role === 'assistant' ? 'Agent' : 'You'}
</span>
{message.status === 'failed' || message.status === 'streaming' ? (
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status === 'streaming' ? 'Working' : 'Failed'}
</Badge>
) : null}
</div>
<p className='whitespace-pre-wrap'>
{message.content ||
@@ -276,6 +300,23 @@ export const AgentThread = ({
</p>
</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) => (
<article
key={change._id}
@@ -356,6 +397,7 @@ export const AgentThread = ({
</article>
))}
{visibleMessages.length === 0 &&
visibleToolMessages.length === 0 &&
visibleEvents.length === 0 &&
visibleChanges.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`);
if (!response.ok) {
setAgentTurnActive(false);
const body = await response.text();
if (body.includes('workspace is not active')) {
setWorkspaceError(body);
}
return;
}
const data = (await response.json()) as { active?: boolean };
setWorkspaceError(undefined);
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
+100
View File
@@ -115,6 +115,106 @@ describe('component test harness', () => {
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', () => {
mockUseParams.mockReturnValue({ threadId: 'thread-1' });
mockUseQuery.mockReturnValue({