Add ESLint: typescript-eslint strict-type-checked + stylistic formatting

Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).

Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.

yarn lint / yarn lint:fix added.
This commit is contained in:
Tianyi Cui
2026-06-11 14:17:58 +08:00
parent d2fb352f3e
commit cb6bee3d03
17 changed files with 993 additions and 49 deletions
+2 -2
View File
@@ -65,7 +65,7 @@ export class LoopAgent implements Agent {
steer(content: ContentBlock[], options?: SendOptions): void {
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
if (this._status !== 'running') return this.send(content, options)
if (this._status !== 'running') { this.send(content, options); return }
const source = this.resolveSource(options)
this.inbox.steer({ content, source })
this.ctx.emit('agent/queued', this, content, { source, steering: true })
@@ -88,7 +88,7 @@ export class LoopAgent implements Agent {
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {
setStatus: status => this.setStatus(status),
setStatus: (status) => { this.setStatus(status) },
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
isDisposed: () => this._status === 'disposed',
+11 -9
View File
@@ -113,7 +113,9 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// Drain queued messages into the session — they trigger this turn.
const queued = agent.inbox.drainQueued()
const trigger: TurnTrigger = { kind: 'message', source: queued[0]!.source }
const first = queued[0]
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
const trigger: TurnTrigger = { kind: 'message', source: first.source }
for (const message of queued) {
session.append('user/message', { content: message.content, source: message.source })
}
@@ -177,7 +179,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
try {
shouldContinue = await ctx.waterfall(
'agent/turn-continuation', agent, turn, defaultDecision,
async () => defaultDecision,
() => Promise.resolve(defaultDecision),
)
} catch (error: unknown) {
// A broken continuation plugin ends the turn, not the loop.
@@ -246,7 +248,7 @@ async function runStep(
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
signal,
}
request = await ctx.waterfall('agent/request', agent, turn, step, request, async () => request)
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
if (!request.model) {
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
}
@@ -264,7 +266,7 @@ async function runStep(
// source of truth for derived history and replay) records the message that
// tool dispatch actually uses.
let message: Message = assembler.message()
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, async () => message)
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
session.append('assistant/message', { turn, step, content: message.content })
if (assembler.usage) {
@@ -297,6 +299,9 @@ async function runStep(
content: result.content,
isError: result.isError,
})
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
}
@@ -305,9 +310,6 @@ async function runStep(
/** The last turn number in a (possibly seeded) session log, or 0. */
function lastTurnNumber(session: Session): number {
for (let index = session.events.length - 1; index >= 0; index--) {
const event = session.events[index]!
if (event.type === 'turn/start') return event.data.turn
}
return 0
const lastStart = session.events.findLast(event => event.type === 'turn/start')
return lastStart?.data.turn ?? 0
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { StreamChunk, ToolResultBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionEventType, TurnEndReason } from '@deepseek-ai/dsh-session'
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -99,7 +99,7 @@ describe('agent loop', () => {
expect(toolResultMessage).toBeDefined()
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
expect((block as ToolResultBlock).content).toEqual([{ type: 'text', text: 'echo: ping' }])
expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
// session log records call + result
const types = agent.session.events.map(e => e.type)
@@ -380,7 +380,7 @@ describe('agent loop', () => {
expect(agent.status).toBe('disposed')
expect(ctx.agents.get('scoped')).toBeUndefined()
expect(() => send(agent, 'too late')).toThrow('disposed')
expect(() => { send(agent, 'too late') }).toThrow('disposed')
})
it('replays a session log into an identical derived history', async () => {
@@ -405,6 +405,6 @@ describe('agent loop', () => {
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
// event-by-event identity of types
expect(replayed.events.map(e => e.type)).toEqual(
agent.session.events.map(e => e.type as SessionEventType))
agent.session.events.map(e => e.type))
})
})
+3 -3
View File
@@ -5,7 +5,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
export function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
...[...text].map((char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'stop' } },
@@ -60,8 +60,8 @@ export class MockAdapter extends LlmAdapter {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
await new Promise<void>((_resolve, reject) => {
if (options.signal?.aborted) return reject(new Error('aborted'))
options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
if (options.signal?.aborted) { reject(new Error('aborted')); return }
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
+4 -1
View File
@@ -35,7 +35,7 @@ export class AgentRegistry extends Service {
* when the calling fiber is disposed. Returns the disposer.
*/
register(agent: Agent): () => void {
return this.ctx.effect(() => {
const dispose = this.ctx.effect(() => {
if (this.store.has(agent.id)) {
throw new Error(`agent "${agent.id}" is already registered`)
}
@@ -46,6 +46,9 @@ export class AgentRegistry extends Service {
this.ctx.emit('agent/disposed', agent)
}
}, 'agents.register()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
get(id: string): Agent | undefined {
+14 -5
View File
@@ -109,9 +109,16 @@ export class BlockAssembler {
}
}
/** Invariant accessor: every index in `order` has a partial. */
private mustGet(index: number): PartialBlock {
const partial = this.partials.get(index)
if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`)
return partial
}
/** Assemble all blocks seen so far, in stream order. */
blocks(): ContentBlock[] {
return this.order.map(index => this.assemble(this.partials.get(index)!, index))
return this.order.map(index => this.assemble(this.mustGet(index), index))
}
/**
@@ -123,8 +130,9 @@ export class BlockAssembler {
flushReady(): ContentBlock[] {
const ready: ContentBlock[] = []
while (this.flushed < this.order.length) {
const index = this.order[this.flushed]!
const partial = this.partials.get(index)!
const index = this.order[this.flushed]
if (index === undefined) break
const partial = this.mustGet(index)
if (!partial.block) break
ready.push(partial.block)
this.flushed += 1
@@ -141,8 +149,9 @@ export class BlockAssembler {
flushRemaining(): ContentBlock[] {
const remaining: ContentBlock[] = []
while (this.flushed < this.order.length) {
const index = this.order[this.flushed]!
remaining.push(this.assemble(this.partials.get(index)!, index))
const index = this.order[this.flushed]
if (index === undefined) break
remaining.push(this.assemble(this.mustGet(index), index))
this.flushed += 1
}
return remaining
+4 -1
View File
@@ -69,7 +69,7 @@ export class LlmService extends Service {
* fiber.
*/
registerAdapter(models: string[], adapter: LlmAdapter): () => void {
return this.ctx.effect(() => {
const dispose = this.ctx.effect(() => {
for (const model of models) {
if (this.adapters.has(model)) {
throw new LlmError(`an adapter for model "${model}" is already registered`, 'DUPLICATE_ADAPTER')
@@ -82,6 +82,9 @@ export class LlmService extends Service {
this.ctx.emit('llm/adapter-change')
}
}, 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/** Model names with a registered adapter. */
+5 -1
View File
@@ -97,6 +97,10 @@ export class Session {
deriveMessages(): Message[] {
const messages: Message[] = []
for (const event of this.log) {
// Intentionally non-exhaustive: only message-producing events derive
// history; turn/step boundaries, chunks, usage, and errors are
// trace/replay data.
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (event.type) {
case 'user/message': {
messages.push({ role: 'user', content: event.data.content })
@@ -155,7 +159,7 @@ export class SessionStore extends Service {
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
const session = new Session(id, seed)
this.ctx.effect(() => {
session.onAppend = (event) => this.ctx.emit('session/event', session, event)
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(id, session)
this.ctx.emit('session/created', session)
return () => {
+9 -3
View File
@@ -73,7 +73,7 @@ export class SystemPrompt extends Service {
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
*/
section(section: PromptSection): () => void {
return this.ctx.effect(() => {
const dispose = this.ctx.effect(() => {
this.sections.push(section)
this.ctx.emit('system-prompt/change')
return () => {
@@ -82,6 +82,9 @@ export class SystemPrompt extends Service {
this.ctx.emit('system-prompt/change')
}
}, 'systemPrompt.section()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/**
@@ -90,7 +93,7 @@ export class SystemPrompt extends Service {
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
*/
tools(provider: () => ToolSchema[]): () => void {
return this.ctx.effect(() => {
const dispose = this.ctx.effect(() => {
this.toolProviders.push(provider)
this.ctx.emit('system-prompt/change')
return () => {
@@ -99,6 +102,9 @@ export class SystemPrompt extends Service {
this.ctx.emit('system-prompt/change')
}
}, 'systemPrompt.tools()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
/**
@@ -113,7 +119,7 @@ export class SystemPrompt extends Service {
sections: [...this.sections].sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider => provider()),
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, async () => assembly)
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
}
}
@@ -8,7 +8,7 @@ describe('SystemPrompt', () => {
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'persona', order: 0, text: 'You are DeepSeek Code.' })
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => `cwd: /tmp` })
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
+6 -1
View File
@@ -107,7 +107,7 @@ export class ToolRegistry extends Service {
* with the calling fiber. Emits `tools/change` on register/unregister.
*/
register(definition: ToolDefinition): () => void {
return this.ctx.effect(() => {
const dispose = this.ctx.effect(() => {
if (this.store.has(definition.name)) {
throw new Error(`tool "${definition.name}" is already registered`)
}
@@ -118,6 +118,9 @@ export class ToolRegistry extends Service {
this.ctx.emit('tools/change')
}
}, 'tools.register()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
get(name: string): ToolDefinition | undefined {
@@ -130,6 +133,8 @@ export class ToolRegistry extends Service {
* assembly.
*/
schemas(): ToolSchema[] {
// Rest-destructure to drop `execute`; the unused binding is the idiom.
// eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unused-vars
return [...this.store.values()].map(({ execute, ...schema }) => schema)
}
+12 -9
View File
@@ -60,11 +60,11 @@ export type SchemaSpec = Record<string, SchemaProp>
/** Map a {@link SchemaType} to its TS primitive type. */
type TypeOf<T extends SchemaType> =
T extends 'string' ? string :
T extends 'number' ? number :
T extends 'boolean' ? boolean :
T extends 'object' ? Record<string, unknown> :
T extends 'array' ? unknown[] :
never
T extends 'number' ? number :
T extends 'boolean' ? boolean :
T extends 'object' ? Record<string, unknown> :
T extends 'array' ? unknown[] :
never
/** Flatten an intersection into one object type for readable hovers. */
type Simplify<T> = { [K in keyof T]: T[K] } & {}
@@ -82,8 +82,8 @@ type RequiredKeys<S extends SchemaSpec> =
*/
type InferPropValue<P extends SchemaProp> =
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
TypeOf<P['type']>
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
TypeOf<P['type']>
/**
* Infer the TS argument type for a complete {@link SchemaSpec}.
@@ -117,7 +117,7 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>;
if (prop.enum) result.enum = prop.enum
if (prop.default !== undefined) result.default = prop.default
let required = prop.required === true
const required = prop.required === true
if (prop.type === 'object' && prop.properties) {
const nested = schemaSpecToJsonSchema(prop.properties)
@@ -224,6 +224,9 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...options.strict !== undefined ? { strict: options.strict } : {},
execute: options.execute as ToolDefinition['execute'],
// Object-literal execute methods don't use `this`; passing the reference
// through is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
execute: options.execute,
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ const echoTool = defineTool({
description: 'echo arguments back',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text' as const, text: String(args.text ?? '') }]
return [{ type: 'text' as const, text: args.text ?? '' }]
},
})
@@ -372,7 +372,7 @@ describe('schema DSL regressions (Codex review round 2)', () => {
...echoTool,
name: 'object-thrower',
async execute() {
// eslint-disable-next-line no-throw-literal — testing non-Error throws
// testing non-Error throws on purpose
throw { message: 'denied by object' }
},
})