Files
extension/packages/imanage/test/hanzo.test.ts
T
Hanzo Dev 8de63fa05f feat(imanage): Hanzo AI for iManage Work — legal DMS panel + OAuth/Work-API proxy
New package @hanzo/imanage: an embedded-app panel over @hanzo/ai for iManage
Work (the dominant legal document management system), mirroring the
packages/procore OAuth + API-proxy + pure-core shape.

- OAuth2 Authorization Code against the iManage Control Center
  (/auth/oauth2/authorize + /token); server-side token exchange + refresh, the
  client secret never reaches the browser.
- Work API v2 wrappers (workspaces, folder children, document profile +
  content, search, gated profile write-back) scoped by customer + library in
  the path, X-Auth-Token auth, envelope/offset-limit pagination. Pure.
- Pure legal document-context assembly with text extraction (binary/OCR out of
  scope, reported honestly) and one truncation-honest windowing algorithm.
- Four AI actions — summarize document, extract key clauses/dates/parties,
  search-and-synthesize a matter, compare a document set — plus freeform ask,
  over a single grounded ask() path.
- Same-origin proxy keeps tokens + secret server-side; NEVER logs document
  content. Legal confidentiality posture documented in the README.
- 121 vitest tests (config/oauth/api/parse/documents/hanzo/actions/panel),
  tsc --noEmit clean, node build.js green.

Registers packages/imanage in pnpm-workspace.yaml (one line).
2026-07-04 02:01:19 -07:00

127 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
import {
SYSTEM_PROMPT,
buildMessages,
extractContent,
ask,
listModels,
type MatterMeta,
} from '../src/hanzo.js';
import { buildContext, type DocumentContext } from '../src/documents.js';
const ctx: DocumentContext = buildContext([{ title: 'Document', blocks: ['Document: NDA — mutual'] }]);
const meta: MatterMeta = { workspaceName: 'Acme / Falcon', matterNumber: '2026-0042', client: 'Acme Corp' };
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
return {
id: 'c1',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
};
}
// mockClient captures the params the code sends and returns a canned completion.
function mockClient(reply: ChatCompletion, models: Model[] = []) {
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
return { client, create, list };
}
describe('hanzo: buildMessages', () => {
it('produces a grounded system prompt + a fenced user turn with matter meta + context note', () => {
const msgs = buildMessages('Does this NDA survive termination?', ctx, meta);
expect(msgs).toHaveLength(2);
expect(msgs[0].role).toBe('system');
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
const user = String(msgs[1].content);
expect(user).toContain('Workspace: Acme / Falcon');
expect(user).toContain('Matter #: 2026-0042');
expect(user).toContain('Client: Acme Corp');
expect(user).toContain('---- document records ----');
expect(user).toContain('Document: NDA — mutual');
expect(user).toContain('---- task ----');
expect(user).toContain('Does this NDA survive termination?');
});
it('omits the header block entirely when meta is undefined', () => {
const user = String(buildMessages('q', ctx).at(1)!.content);
expect(user).not.toContain('Workspace:');
expect(user).not.toContain('Matter #:');
expect(user).toContain('---- task ----');
});
it('SYSTEM_PROMPT forbids inventing terms and disclaims legal advice + affirms confidentiality', () => {
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
expect(SYSTEM_PROMPT).toMatch(/not legal advice/i);
expect(SYSTEM_PROMPT).toMatch(/confidential/i);
});
});
describe('hanzo: extractContent', () => {
it('reads a plain string content', () => {
expect(extractContent(completion('hello'))).toBe('hello');
});
it('concatenates text parts of an array content', () => {
const parts = [
{ type: 'text', text: 'a' },
{ type: 'image_url', image_url: { url: 'x' } },
{ type: 'text', text: 'b' },
] as any;
expect(extractContent(completion(parts))).toBe('ab');
});
it('throws on empty content', () => {
expect(() => extractContent(completion(''))).toThrow(/no content/);
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
});
});
describe('hanzo: ask', () => {
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
const { client, create } = mockClient(completion('the NDA survives for 3 years'));
const out = await ask('Does this NDA survive termination?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
expect(out).toBe('the NDA survives for 3 years');
expect(create).toHaveBeenCalledOnce();
const [params, options] = create.mock.calls[0];
expect(params.model).toBe('zen5');
expect(params.stream).toBe(false);
expect(params.temperature).toBe(0.2);
expect(params.messages[0].role).toBe('system');
expect(options).toBeDefined();
});
it('omits temperature from the wire when not provided', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect('temperature' in create.mock.calls[0][0]).toBe(false);
});
it('defaults the model to zen5', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect(create.mock.calls[0][0].model).toBe('zen5');
});
it('propagates an abort signal to the client', async () => {
const { client, create } = mockClient(completion('ok'));
const controller = new AbortController();
await ask('q', ctx, undefined, { client, signal: controller.signal });
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('hanzo: listModels', () => {
it('maps the model catalog to ids', async () => {
const models: Model[] = [
{ id: 'zen5', object: 'model' },
{ id: 'zen5-pro', object: 'model' },
];
const { client } = mockClient(completion('x'), models);
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
});
});