Skip to content

Commit 102d931

Browse files
snomiaoclaude
andcommitted
feat(agent): add AsyncIterable pipe runtime with exit-code short-circuit
Execute parsed Node trees. Pipe stages chain stdout -> stdin as AsyncIterables so downstream commands start consuming before upstream finishes. && / || short-circuit on exit code. Redirect drains final stdout to the VFS. Unknown command -> 127, throwing command -> 1, aborted signal -> 130. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent da1d354 commit 102d931

3 files changed

Lines changed: 322 additions & 13 deletions

File tree

src/agent/shell/runtime.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { CommandRegistryImpl, runScript } from './runtime'
4+
import type { ExecContext } from './runtime'
5+
import { collect, emptyIter, lines, stringIter } from './types'
6+
import type { Command } from './types'
7+
import { MemoryVFS } from './vfs/memory'
8+
9+
function setup(): ExecContext & { registry: CommandRegistryImpl } {
10+
const registry = new CommandRegistryImpl()
11+
const echo: Command = async (ctx) => ({
12+
stdout: stringIter(ctx.argv.slice(1).join(' ') + '\n'),
13+
exitCode: 0
14+
})
15+
const cat: Command = async (ctx) => ({ stdout: ctx.stdin, exitCode: 0 })
16+
const grep: Command = async (ctx) => {
17+
const re = new RegExp(ctx.argv[1])
18+
async function* gen(): AsyncIterable<string> {
19+
for await (const l of lines(ctx.stdin)) {
20+
if (re.test(l)) yield l + '\n'
21+
}
22+
}
23+
return { stdout: gen(), exitCode: 0 }
24+
}
25+
const fail: Command = async () => ({ stdout: emptyIter(), exitCode: 2 })
26+
const count: Command = async (ctx) => {
27+
let n = 0
28+
for await (const _l of lines(ctx.stdin)) n++
29+
return { stdout: stringIter(String(n) + '\n'), exitCode: 0 }
30+
}
31+
const boom: Command = async () => {
32+
throw new Error('kaboom')
33+
}
34+
registry.register('echo', echo)
35+
registry.register('cat', cat)
36+
registry.register('grep', grep)
37+
registry.register('fail', fail)
38+
registry.register('count', count)
39+
registry.register('boom', boom)
40+
return {
41+
registry,
42+
vfs: new MemoryVFS(),
43+
env: new Map(),
44+
cwd: '/'
45+
}
46+
}
47+
48+
describe('runScript', () => {
49+
it('runs simple command', async () => {
50+
const ctx = setup()
51+
const r = await runScript('echo hi', ctx)
52+
expect(r.exitCode).toBe(0)
53+
expect(await collect(r.stdout)).toBe('hi\n')
54+
})
55+
56+
it('pipes through stages', async () => {
57+
const ctx = setup()
58+
const r = await runScript('echo a | cat | cat', ctx)
59+
expect(await collect(r.stdout)).toBe('a\n')
60+
})
61+
62+
it('grep filters piped input', async () => {
63+
const ctx = setup()
64+
const r = await runScript('echo foo | grep oo', ctx)
65+
expect(await collect(r.stdout)).toBe('foo\n')
66+
const r2 = await runScript('echo bar | grep oo', ctx)
67+
expect(await collect(r2.stdout)).toBe('')
68+
})
69+
70+
it('&& short-circuits on failure', async () => {
71+
const ctx = setup()
72+
const r = await runScript('fail && echo nope', ctx)
73+
expect(r.exitCode).toBe(2)
74+
expect(await collect(r.stdout)).toBe('')
75+
})
76+
77+
it('&& runs right on success', async () => {
78+
const ctx = setup()
79+
const r = await runScript('echo a && echo b', ctx)
80+
expect(r.exitCode).toBe(0)
81+
expect(await collect(r.stdout)).toBe('a\nb\n')
82+
})
83+
84+
it('|| runs right on failure', async () => {
85+
const ctx = setup()
86+
const r = await runScript('fail || echo recover', ctx)
87+
expect(r.exitCode).toBe(0)
88+
expect(await collect(r.stdout)).toContain('recover')
89+
})
90+
91+
it('redirect > writes stdout to vfs', async () => {
92+
const ctx = setup()
93+
const r = await runScript('echo hello > /out.txt', ctx)
94+
expect(r.exitCode).toBe(0)
95+
expect(await collect(r.stdout)).toBe('')
96+
expect(await ctx.vfs.read('/out.txt')).toBe('hello\n')
97+
})
98+
99+
it('redirect >> appends', async () => {
100+
const ctx = setup()
101+
await runScript('echo a >> /log', ctx)
102+
await runScript('echo b >> /log', ctx)
103+
expect(await ctx.vfs.read('/log')).toBe('a\nb\n')
104+
})
105+
106+
it('pipe redirect writes final stage output', async () => {
107+
const ctx = setup()
108+
await runScript('echo foo | cat > /p.txt', ctx)
109+
expect(await ctx.vfs.read('/p.txt')).toBe('foo\n')
110+
})
111+
112+
it('unknown command returns 127', async () => {
113+
const ctx = setup()
114+
const r = await runScript('notreal', ctx)
115+
expect(r.exitCode).toBe(127)
116+
expect(r.stderr).toContain('not found')
117+
})
118+
119+
it('throwing command returns 1', async () => {
120+
const ctx = setup()
121+
const r = await runScript('boom', ctx)
122+
expect(r.exitCode).toBe(1)
123+
expect(r.stderr).toContain('kaboom')
124+
})
125+
126+
it('pre-aborted signal returns 130', async () => {
127+
const ctx = setup()
128+
const ac = new AbortController()
129+
ac.abort()
130+
const r = await runScript('echo hi', { ...ctx, signal: ac.signal })
131+
expect(r.exitCode).toBe(130)
132+
})
133+
134+
it('seq runs both sides', async () => {
135+
const ctx = setup()
136+
const r = await runScript('echo a ; echo b', ctx)
137+
expect(await collect(r.stdout)).toBe('a\nb\n')
138+
})
139+
140+
it('count consumes piped lines', async () => {
141+
const ctx = setup()
142+
const r = await runScript('echo a | count', ctx)
143+
expect(await collect(r.stdout)).toBe('1\n')
144+
})
145+
146+
it('parse error returns exit 2', async () => {
147+
const ctx = setup()
148+
const r = await runScript('echo $(ls)', ctx)
149+
expect(r.exitCode).toBe(2)
150+
})
151+
})

src/agent/shell/runtime.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import type {
2+
Cmd,
3+
CmdContext,
4+
CmdResult,
5+
Command,
6+
CommandRegistry,
7+
Node,
8+
Redirect,
9+
VFS
10+
} from './types'
11+
import { collect, emptyIter } from './types'
12+
import { parseScript } from './parser'
13+
14+
export class CommandRegistryImpl implements CommandRegistry {
15+
private map = new Map<string, Command>()
16+
17+
get(name: string): Command | undefined {
18+
return this.map.get(name)
19+
}
20+
21+
register(name: string, cmd: Command): void {
22+
this.map.set(name, cmd)
23+
}
24+
25+
list(): string[] {
26+
return [...this.map.keys()].sort()
27+
}
28+
}
29+
30+
export interface ExecContext {
31+
registry: CommandRegistry
32+
vfs: VFS
33+
env: Map<string, string>
34+
cwd: string
35+
signal?: AbortSignal
36+
stdin?: AsyncIterable<string>
37+
}
38+
39+
function makeCtx(
40+
ctx: ExecContext,
41+
argv: string[],
42+
stdin: AsyncIterable<string>
43+
): CmdContext {
44+
return {
45+
argv,
46+
stdin,
47+
env: ctx.env,
48+
cwd: ctx.cwd,
49+
vfs: ctx.vfs,
50+
signal: ctx.signal ?? new AbortController().signal
51+
}
52+
}
53+
54+
async function applyRedirect(
55+
res: CmdResult,
56+
redirect: Redirect,
57+
vfs: VFS
58+
): Promise<CmdResult> {
59+
const data = await collect(res.stdout)
60+
if (redirect.op === '>') await vfs.write(redirect.path, data)
61+
else await vfs.append(redirect.path, data)
62+
return { stdout: emptyIter(), exitCode: res.exitCode, stderr: res.stderr }
63+
}
64+
65+
async function runSimple(
66+
cmd: Cmd,
67+
ctx: ExecContext,
68+
stdin: AsyncIterable<string>
69+
): Promise<CmdResult> {
70+
if (ctx.signal?.aborted) {
71+
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
72+
}
73+
const name = cmd.argv[0]
74+
const handler = ctx.registry.get(name)
75+
if (!handler) {
76+
return {
77+
stdout: emptyIter(),
78+
exitCode: 127,
79+
stderr: `${name}: command not found`
80+
}
81+
}
82+
let res: CmdResult
83+
try {
84+
res = await handler(makeCtx(ctx, cmd.argv, stdin))
85+
} catch (err) {
86+
return {
87+
stdout: emptyIter(),
88+
exitCode: 1,
89+
stderr: err instanceof Error ? err.message : String(err)
90+
}
91+
}
92+
if (cmd.redirect) res = await applyRedirect(res, cmd.redirect, ctx.vfs)
93+
return res
94+
}
95+
96+
async function runPipe(
97+
cmds: Cmd[],
98+
ctx: ExecContext,
99+
stdin: AsyncIterable<string>,
100+
redirect: Redirect | undefined
101+
): Promise<CmdResult> {
102+
let cur = stdin
103+
let exit = 0
104+
let stderr: string | undefined
105+
for (let i = 0; i < cmds.length; i++) {
106+
const last = i === cmds.length - 1
107+
const cmd = cmds[i]
108+
const inner = last ? cmd : { ...cmd, redirect: undefined }
109+
const res = await runSimple(inner, ctx, cur)
110+
cur = res.stdout
111+
exit = res.exitCode
112+
if (res.stderr) stderr = res.stderr
113+
if (ctx.signal?.aborted) {
114+
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
115+
}
116+
}
117+
let result: CmdResult = { stdout: cur, exitCode: exit, stderr }
118+
if (redirect) result = await applyRedirect(result, redirect, ctx.vfs)
119+
return result
120+
}
121+
122+
async function runNode(node: Node, ctx: ExecContext): Promise<CmdResult> {
123+
const stdin = ctx.stdin ?? emptyIter()
124+
if (ctx.signal?.aborted) {
125+
return { stdout: emptyIter(), exitCode: 130, stderr: 'aborted' }
126+
}
127+
if (node.type === 'simple') return runSimple(node.cmd, ctx, stdin)
128+
if (node.type === 'pipe') return runPipe(node.cmds, ctx, stdin, node.redirect)
129+
130+
const left = await runNode(node.left, ctx)
131+
const leftOut = await collect(left.stdout)
132+
if (node.type === 'and' && left.exitCode !== 0) {
133+
return {
134+
stdout: toIter(leftOut),
135+
exitCode: left.exitCode,
136+
stderr: left.stderr
137+
}
138+
}
139+
if (node.type === 'or' && left.exitCode === 0) {
140+
return { stdout: toIter(leftOut), exitCode: 0, stderr: left.stderr }
141+
}
142+
const right = await runNode(node.right, ctx)
143+
const rightOut = await collect(right.stdout)
144+
const combined = leftOut + rightOut
145+
return {
146+
stdout: toIter(combined),
147+
exitCode: right.exitCode,
148+
stderr: right.stderr ?? left.stderr
149+
}
150+
}
151+
152+
async function* toIter(s: string): AsyncIterable<string> {
153+
if (s.length > 0) yield s
154+
}
155+
156+
export async function runScript(
157+
src: string,
158+
ctx: ExecContext
159+
): Promise<CmdResult> {
160+
let node: Node
161+
try {
162+
node = parseScript(src, Object.fromEntries(ctx.env))
163+
} catch (err) {
164+
return {
165+
stdout: emptyIter(),
166+
exitCode: 2,
167+
stderr: err instanceof Error ? err.message : String(err)
168+
}
169+
}
170+
return runNode(node, ctx)
171+
}

src/agent/shell/vfs/memory.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,6 @@ function normalize(path: string): string {
1212
return '/' + stack.join('/')
1313
}
1414

15-
function parentOf(path: string): string {
16-
const idx = path.lastIndexOf('/')
17-
if (idx <= 0) return '/'
18-
return path.slice(0, idx)
19-
}
20-
21-
function nameOf(path: string): string {
22-
const idx = path.lastIndexOf('/')
23-
return path.slice(idx + 1)
24-
}
25-
2615
export class MemoryVFS implements VFS {
2716
private files = new Map<string, string>()
2817

@@ -95,5 +84,3 @@ export class MemoryVFS implements VFS {
9584
return false
9685
}
9786
}
98-
99-
export { normalize, parentOf, nameOf }

0 commit comments

Comments
 (0)