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
}