fix(agent-teams): harden inbox parsing and behavioral tests

This commit is contained in:
Nguyen Khac Trung Kien 2026-02-08 14:22:02 +07:00 committed by YeonGyu-Kim
parent a9d4cefdfe
commit 1e2c10e7b0
5 changed files with 109 additions and 24 deletions

View File

@ -0,0 +1,59 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { appendInboxMessage, ensureInbox, readInbox } from "./inbox-store"
import { getTeamInboxPath } from "./paths"
describe("agent-teams inbox store", () => {
let originalCwd: string
let tempProjectDir: string
beforeEach(() => {
originalCwd = process.cwd()
tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-inbox-store-"))
process.chdir(tempProjectDir)
})
afterEach(() => {
process.chdir(originalCwd)
rmSync(tempProjectDir, { recursive: true, force: true })
})
test("readInbox fails on malformed inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, "{", "utf-8")
//#when
const readMalformedInbox = () => readInbox("core", "team-lead", false, false)
//#then
expect(readMalformedInbox).toThrow("team_inbox_parse_failed")
expect(readFileSync(inboxPath, "utf-8")).toBe("{")
})
test("appendInboxMessage fails on schema-invalid inbox JSON without overwriting file", () => {
//#given
ensureInbox("core", "team-lead")
const inboxPath = getTeamInboxPath("core", "team-lead")
writeFileSync(inboxPath, JSON.stringify({ invalid: true }), "utf-8")
//#when
const appendIntoInvalidInbox = () => {
appendInboxMessage("core", "team-lead", {
from: "team-lead",
text: "hello",
timestamp: new Date().toISOString(),
read: false,
summary: "note",
})
}
//#then
expect(appendIntoInvalidInbox).toThrow("team_inbox_schema_invalid")
expect(readFileSync(inboxPath, "utf-8")).toBe(JSON.stringify({ invalid: true }))
})
})

View File

@ -42,13 +42,20 @@ function withInboxLock<T>(teamName: string, operation: () => T): T {
}
function parseInboxFile(content: string): TeamInboxMessage[] {
let parsed: unknown
try {
const parsed = JSON.parse(content)
const result = TeamInboxListSchema.safeParse(parsed)
return result.success ? result.data : []
parsed = JSON.parse(content)
} catch {
return []
throw new Error("team_inbox_parse_failed")
}
const result = TeamInboxListSchema.safeParse(parsed)
if (!result.success) {
throw new Error("team_inbox_schema_invalid")
}
return result.data
}
function readInboxMessages(teamName: string, agentName: string): TeamInboxMessage[] {

View File

@ -1,10 +1,10 @@
/// <reference types="bun-types" />
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { chmodSync, existsSync, mkdtempSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { acquireLock } from "../../features/claude-tasks/storage"
import { getTeamDir, getTeamTaskDir } from "./paths"
import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths"
import { createTeamConfig, deleteTeamData, teamExists } from "./team-config-store"
describe("agent-teams team config store", () => {
@ -69,21 +69,26 @@ describe("agent-teams team config store", () => {
expect(teamExists("core")).toBe(false)
})
test("deleteTeamData removes task files before team files", () => {
test("deleteTeamData removes task files before deleting team directory", () => {
//#given
const sourceUrl = new URL("./team-config-store.ts", import.meta.url)
const source = readFileSync(sourceUrl, "utf-8")
const deleteFnStart = source.indexOf("export function deleteTeamData")
const deleteFnSlice = deleteFnStart >= 0 ? source.slice(deleteFnStart, deleteFnStart + 700) : ""
const taskDir = getTeamTaskDir("core")
const teamDir = getTeamDir("core")
const teamsRootDir = getTeamsRootDir()
expect(existsSync(taskDir)).toBe(true)
expect(existsSync(teamDir)).toBe(true)
//#when
const taskDeleteIndex = deleteFnSlice.indexOf("rmSync(taskDir")
const teamDeleteIndex = deleteFnSlice.indexOf("rmSync(teamDir")
chmodSync(teamsRootDir, 0o555)
try {
const deleteWithBlockedTeamParent = () => deleteTeamData("core")
expect(deleteWithBlockedTeamParent).toThrow()
} finally {
chmodSync(teamsRootDir, 0o755)
}
//#then
expect(deleteFnStart).toBeGreaterThanOrEqual(0)
expect(taskDeleteIndex).toBeGreaterThanOrEqual(0)
expect(teamDeleteIndex).toBeGreaterThanOrEqual(0)
expect(taskDeleteIndex).toBeLessThan(teamDeleteIndex)
expect(existsSync(taskDir)).toBe(false)
expect(existsSync(teamDir)).toBe(true)
})
})

View File

@ -1,14 +1,24 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { buildTeamParentToolContext } from "./teammate-parent-context"
describe("agent-teams teammate parent context", () => {
test("forwards incoming abort signal to parent context resolver", () => {
//#given
const sourceUrl = new URL("./teammate-parent-context.ts", import.meta.url)
const source = readFileSync(sourceUrl, "utf-8")
const abortSignal = new AbortController().signal
//#when
const parentToolContext = buildTeamParentToolContext({
sessionID: "ses-main",
messageID: "msg-main",
agent: "sisyphus",
abort: abortSignal,
})
//#then
expect(source.includes("abort: context.abort ?? new AbortController().signal")).toBe(true)
expect(parentToolContext.abort).toBe(abortSignal)
expect(parentToolContext.sessionID).toBe("ses-main")
expect(parentToolContext.messageID).toBe("msg-main")
expect(parentToolContext.agent).toBe("sisyphus")
})
})

View File

@ -3,11 +3,15 @@ import { resolveParentContext } from "../delegate-task/executor"
import type { ToolContextWithMetadata } from "../delegate-task/types"
import type { TeamToolContext } from "./types"
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
return resolveParentContext({
export function buildTeamParentToolContext(context: TeamToolContext): ToolContextWithMetadata {
return {
sessionID: context.sessionID,
messageID: context.messageID,
agent: context.agent ?? "sisyphus",
abort: context.abort ?? new AbortController().signal,
} as ToolContextWithMetadata)
}
}
export function resolveTeamParentContext(context: TeamToolContext): ParentContext {
return resolveParentContext(buildTeamParentToolContext(context))
}