feat(ipuaro): add session management (v0.10.0)

- Add ISessionStorage interface and RedisSessionStorage implementation
- Add ContextManager for token budget and compression
- Add StartSession, HandleMessage, UndoChange use cases
- Update CHANGELOG and TODO documentation
- 88 new tests (1174 total), 97.73% coverage
This commit is contained in:
imfozilbek
2025-12-01 12:27:22 +05:00
parent 56643d903f
commit 0f2ed5b301
22 changed files with 2798 additions and 261 deletions

View File

@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult: boolean = true,
): ToolContext {
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
@@ -47,9 +44,7 @@ function createMockContext(
}
}
function createMockStatusResult(
overrides: Partial<StatusResult> = {},
): StatusResult {
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
return {
not_added: [],
conflicted: [],
@@ -70,9 +65,7 @@ function createMockStatusResult(
} as StatusResult
}
function createMockCommitResult(
overrides: Partial<CommitResult> = {},
): CommitResult {
function createMockCommitResult(overrides: Partial<CommitResult> = {}): CommitResult {
return {
commit: "abc1234",
branch: "main",
@@ -96,9 +89,7 @@ function createMockGit(options: {
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
status: vi.fn().mockResolvedValue(
options.status ?? createMockStatusResult(),
),
status: vi.fn().mockResolvedValue(options.status ?? createMockStatusResult()),
add: vi.fn(),
commit: vi.fn(),
}
@@ -112,9 +103,7 @@ function createMockGit(options: {
if (options.error) {
mockGit.commit.mockRejectedValue(options.error)
} else {
mockGit.commit.mockResolvedValue(
options.commitResult ?? createMockCommitResult(),
)
mockGit.commit.mockResolvedValue(options.commitResult ?? createMockCommitResult())
}
return mockGit as unknown as SimpleGit
@@ -175,21 +164,15 @@ describe("GitCommitTool", () => {
})
it("should return null for valid message with files", () => {
expect(
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
).toBeNull()
expect(tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] })).toBeNull()
})
it("should return error for non-array files", () => {
expect(
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
).toContain("array")
expect(tool.validateParams({ message: "fix: bug", files: "a.ts" })).toContain("array")
})
it("should return error for non-string in files array", () => {
expect(
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
).toContain("strings")
expect(tool.validateParams({ message: "fix: bug", files: [1, 2] })).toContain("strings")
})
})
@@ -200,10 +183,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
@@ -218,10 +198,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Nothing to commit")
@@ -241,10 +218,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "feat: new feature" },
ctx,
)
const result = await toolWithMock.execute({ message: "feat: new feature" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
@@ -268,10 +242,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
@@ -290,10 +261,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute(
{ message: "test", files: ["a.ts", "b.ts"] },
ctx,
)
await toolWithMock.execute({ message: "test", files: ["a.ts", "b.ts"] }, ctx)
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
})
@@ -303,10 +271,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute(
{ message: "test", files: [] },
ctx,
)
await toolWithMock.execute({ message: "test", files: [] }, ctx)
expect(mockGit.add).not.toHaveBeenCalled()
})
@@ -337,8 +302,8 @@ describe("GitCommitTool", () => {
await toolWithMock.execute({ message: "test commit" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalled()
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
.mock.calls[0][0] as string
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>).mock
.calls[0][0] as string
expect(confirmMessage).toContain("Committing")
expect(confirmMessage).toContain("test commit")
})
@@ -348,10 +313,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, false)
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("cancelled")
@@ -363,10 +325,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, true)
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(true)
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
@@ -381,10 +340,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git commit failed")
@@ -400,10 +356,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
@@ -416,10 +369,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
@@ -431,10 +381,7 @@ describe("GitCommitTool", () => {
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
expect(result.callId).toMatch(/^git_commit-\d+$/)
})

View File

@@ -69,9 +69,7 @@ function createMockGit(options: {
if (options.error) {
mockGit.diffSummary.mockRejectedValue(options.error)
} else {
mockGit.diffSummary.mockResolvedValue(
options.diffSummary ?? createMockDiffSummary(),
)
mockGit.diffSummary.mockResolvedValue(options.diffSummary ?? createMockDiffSummary())
mockGit.diff.mockResolvedValue(options.diff ?? "")
}
@@ -224,9 +222,7 @@ describe("GitDiffTool", () => {
it("should handle binary files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
],
files: [{ file: "image.png", insertions: 0, deletions: 0, binary: true }],
}),
})
const toolWithMock = new GitDiffTool(() => mockGit)
@@ -293,11 +289,7 @@ describe("GitDiffTool", () => {
)
expect(result.success).toBe(true)
expect(mockGit.diffSummary).toHaveBeenCalledWith([
"--cached",
"--",
"src/index.ts",
])
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached", "--", "src/index.ts"])
})
it("should return null pathFilter when not provided", async () => {

View File

@@ -14,12 +14,8 @@ describe("CommandSecurity", () => {
describe("constructor", () => {
it("should use default blacklist and whitelist", () => {
expect(security.getBlacklist()).toEqual(
DEFAULT_BLACKLIST.map((c) => c.toLowerCase()),
)
expect(security.getWhitelist()).toEqual(
DEFAULT_WHITELIST.map((c) => c.toLowerCase()),
)
expect(security.getBlacklist()).toEqual(DEFAULT_BLACKLIST.map((c) => c.toLowerCase()))
expect(security.getWhitelist()).toEqual(DEFAULT_WHITELIST.map((c) => c.toLowerCase()))
})
it("should accept custom blacklist and whitelist", () => {

View File

@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult: boolean = true,
): ToolContext {
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
@@ -48,10 +45,7 @@ function createMockContext(
}
type ExecResult = { stdout: string; stderr: string }
type ExecFn = (
command: string,
options: Record<string, unknown>,
) => Promise<ExecResult>
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
function createMockExec(options: {
stdout?: string
@@ -123,27 +117,19 @@ describe("RunCommandTool", () => {
})
it("should return error for non-number timeout", () => {
expect(
tool.validateParams({ command: "ls", timeout: "5000" }),
).toContain("number")
expect(tool.validateParams({ command: "ls", timeout: "5000" })).toContain("number")
})
it("should return error for negative timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain(
"positive",
)
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain("positive")
})
it("should return error for zero timeout", () => {
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain(
"positive",
)
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain("positive")
})
it("should return error for timeout > 10 minutes", () => {
expect(
tool.validateParams({ command: "ls", timeout: 600001 }),
).toContain("600000")
expect(tool.validateParams({ command: "ls", timeout: 600001 })).toContain("600000")
})
it("should return null for valid timeout", () => {
@@ -180,10 +166,7 @@ describe("RunCommandTool", () => {
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ command: "git push --force" },
ctx,
)
const result = await toolWithMock.execute({ command: "git push --force" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("blocked")
@@ -250,10 +233,7 @@ describe("RunCommandTool", () => {
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext(undefined, true)
const result = await toolWithMock.execute(
{ command: "custom-script" },
ctx,
)
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
expect(result.success).toBe(true)
const data = result.data as RunCommandResult
@@ -266,10 +246,7 @@ describe("RunCommandTool", () => {
const toolWithMock = new RunCommandTool(undefined, execFn)
const ctx = createMockContext(undefined, false)
const result = await toolWithMock.execute(
{ command: "custom-script" },
ctx,
)
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("cancelled")
@@ -364,10 +341,7 @@ describe("RunCommandTool", () => {
await toolWithMock.execute({ command: "ls" }, ctx)
expect(execFn).toHaveBeenCalledWith(
"ls",
expect.objectContaining({ timeout: 30000 }),
)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 }))
})
it("should use custom timeout", async () => {
@@ -377,10 +351,7 @@ describe("RunCommandTool", () => {
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
expect(execFn).toHaveBeenCalledWith(
"ls",
expect.objectContaining({ timeout: 5000 }),
)
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
})
it("should execute in project root", async () => {
@@ -493,10 +464,7 @@ describe("RunCommandTool", () => {
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
const result = await toolWithMock.execute(
{ command: "custom-safe arg" },
ctx,
)
const result = await toolWithMock.execute({ command: "custom-safe arg" }, ctx)
expect(result.success).toBe(true)
expect(ctx.requestConfirmation).not.toHaveBeenCalled()

View File

@@ -45,10 +45,7 @@ function createMockContext(storage?: IStorage): ToolContext {
}
type ExecResult = { stdout: string; stderr: string }
type ExecFn = (
command: string,
options: Record<string, unknown>,
) => Promise<ExecResult>
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
function createMockExec(options: {
stdout?: string
@@ -127,9 +124,7 @@ describe("RunTestsTool", () => {
})
it("should return null for valid params", () => {
expect(
tool.validateParams({ path: "src", filter: "login", watch: true }),
).toBeNull()
expect(tool.validateParams({ path: "src", filter: "login", watch: true })).toBeNull()
})
it("should return error for invalid path", () => {