feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
349
apps/mobile-rn/__tests__/action-service.test.ts
Normal file
349
apps/mobile-rn/__tests__/action-service.test.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
const mockCreateMeetingWorkspace = jest.fn()
|
||||
const mockGetMeetingDetail = jest.fn()
|
||||
const mockCreateMeetingMemo = jest.fn()
|
||||
const mockSearchKnowledge = jest.fn()
|
||||
|
||||
jest.mock('../src/features/meetings/meetings-service', () => {
|
||||
class MeetingServiceError extends Error {
|
||||
public readonly code: string
|
||||
|
||||
constructor(errorCode: string, message?: string) {
|
||||
super(message ?? errorCode)
|
||||
this.code = errorCode
|
||||
this.name = 'MeetingServiceError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
createMeetingWorkspace: (...args: unknown[]) => mockCreateMeetingWorkspace(...args),
|
||||
getMeetingDetail: (...args: unknown[]) => mockGetMeetingDetail(...args),
|
||||
createMeetingMemo: (...args: unknown[]) => mockCreateMeetingMemo(...args),
|
||||
MeetingServiceError,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('../src/features/knowledge/knowledge-service', () => {
|
||||
class KnowledgeServiceError extends Error {
|
||||
public readonly code: string
|
||||
public readonly retryable: boolean
|
||||
public readonly status: number | null
|
||||
|
||||
constructor(
|
||||
errorCode: string,
|
||||
retryable: boolean,
|
||||
status: number | null = null,
|
||||
) {
|
||||
super(errorCode)
|
||||
this.code = errorCode
|
||||
this.retryable = retryable
|
||||
this.status = status
|
||||
this.name = 'KnowledgeServiceError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
searchKnowledge: (...args: unknown[]) => mockSearchKnowledge(...args),
|
||||
KnowledgeServiceError,
|
||||
}
|
||||
})
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
ActionServiceError,
|
||||
executeConfirmedAction,
|
||||
listActionHistory,
|
||||
parseActionCommand,
|
||||
parseActionJson,
|
||||
} from '../src/features/actions/action-service'
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const MEETING_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const MEMO_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.restoreAllMocks()
|
||||
mockCreateMeetingWorkspace.mockReset()
|
||||
mockGetMeetingDetail.mockReset()
|
||||
mockCreateMeetingMemo.mockReset()
|
||||
mockSearchKnowledge.mockReset()
|
||||
await AsyncStorage.clear()
|
||||
})
|
||||
|
||||
describe('action parser contract', () => {
|
||||
it('accepts only strictly validated allowlisted JSON', () => {
|
||||
expect(parseActionJson(JSON.stringify({
|
||||
type: 'create_meeting',
|
||||
args: { title: ' Product review ' },
|
||||
rationale: 'The user explicitly requested a meeting workspace.',
|
||||
}))).toEqual({
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Product review' },
|
||||
rationale: 'The user explicitly requested a meeting workspace.',
|
||||
executable: true,
|
||||
})
|
||||
|
||||
expect(parseActionJson(JSON.stringify({
|
||||
type: 'search_knowledge',
|
||||
args: { query: 'release policy' },
|
||||
rationale: 'This is a read-only knowledge search.',
|
||||
}))).toMatchObject({ type: 'search_knowledge', executable: true })
|
||||
})
|
||||
|
||||
it('blocks external invitations and unknown commands after parsing', () => {
|
||||
expect(parseActionJson(JSON.stringify({
|
||||
type: 'send_team_invite',
|
||||
args: { team_id: MEETING_ID, email: 'member@example.org', role: 'member' },
|
||||
rationale: 'The user asked for a team invitation.',
|
||||
}))).toMatchObject({
|
||||
type: 'send_team_invite',
|
||||
executable: false,
|
||||
blockReason: 'external-side-effect',
|
||||
})
|
||||
|
||||
expect(parseActionJson(JSON.stringify({
|
||||
type: 'unknown',
|
||||
args: {},
|
||||
rationale: 'The request lacks a supported action.',
|
||||
}))).toMatchObject({ executable: false, blockReason: 'unknown-action' })
|
||||
})
|
||||
|
||||
it('rejects markdown fences, prose, extra keys, and invented missing identifiers', () => {
|
||||
const validObject = '{"type":"unknown","args":{},"rationale":"Unsupported"}'
|
||||
expect(() => parseActionJson(`\`\`\`json\n${validObject}\n\`\`\``))
|
||||
.toThrow(ActionServiceError)
|
||||
expect(() => parseActionJson(`${validObject}\nThis is the result.`))
|
||||
.toThrow(ActionServiceError)
|
||||
expect(() => parseActionJson(JSON.stringify({
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Review', hidden: 'side effect' },
|
||||
rationale: 'Create it.',
|
||||
}))).toThrow(ActionServiceError)
|
||||
expect(() => parseActionJson(JSON.stringify({
|
||||
type: 'create_memo',
|
||||
args: { content: 'Memo without meeting id' },
|
||||
rationale: 'Missing the required ID.',
|
||||
}))).toThrow(ActionServiceError)
|
||||
})
|
||||
|
||||
it('calls authenticated llm-proxy and rejects a multi-block response', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||
content: [
|
||||
{ type: 'text', text: '{"type":"unknown","args":{},"rationale":"one"}' },
|
||||
{ type: 'text', text: '{"type":"unknown","args":{},"rationale":"two"}' },
|
||||
],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'X-D3RO-Generation-Id': '22222222-2222-4222-8222-222222222222' },
|
||||
}))
|
||||
|
||||
await expect(parseActionCommand('Do something', { accessToken: 'access-token' }))
|
||||
.rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/functions/v1/llm-proxy'),
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
const request = fetchMock.mock.calls[0][1] as RequestInit
|
||||
expect((request.headers as Record<string, string>).Authorization).toBe('Bearer access-token')
|
||||
expect((request.headers as Record<string, string>)['X-D3RO-Generation-Purpose'])
|
||||
.toBe('action_response')
|
||||
expect(JSON.parse(String(request.body))).toEqual(expect.objectContaining({
|
||||
stream: false,
|
||||
max_tokens: 512,
|
||||
}))
|
||||
})
|
||||
|
||||
it('returns a reportable generation receipt with the exact parsed action snapshot', async () => {
|
||||
const snapshot = JSON.stringify({
|
||||
type: 'unknown',
|
||||
args: {},
|
||||
rationale: 'This action is not supported.',
|
||||
})
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
||||
content: [{ type: 'text', text: snapshot }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'X-D3RO-Generation-Id': '22222222-2222-4222-8222-222222222222' },
|
||||
}))
|
||||
|
||||
await expect(parseActionCommand('Do something', { accessToken: 'access-token' }))
|
||||
.resolves.toMatchObject({
|
||||
generationId: '22222222-2222-4222-8222-222222222222',
|
||||
snapshot,
|
||||
action: { type: 'unknown', executable: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmed action execution and local audit history', () => {
|
||||
it('never executes an allowed action without an explicit confirmation flag', async () => {
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Review' },
|
||||
rationale: 'Create a meeting.',
|
||||
}))
|
||||
await expect(executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Create a review meeting',
|
||||
action,
|
||||
confirmed: false,
|
||||
})).rejects.toMatchObject({ code: 'ACTION_BLOCKED' })
|
||||
expect(mockCreateMeetingWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates a caller-forged action object before any side effect', async () => {
|
||||
const forged = {
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Review', hidden: 'unreviewed' },
|
||||
rationale: 'Create a meeting.',
|
||||
executable: true,
|
||||
} as unknown as ReturnType<typeof parseActionJson>
|
||||
await expect(executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Create a review meeting',
|
||||
action: forged,
|
||||
confirmed: true,
|
||||
})).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
|
||||
expect(mockCreateMeetingWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a real meeting and records the confirmed result per user', async () => {
|
||||
mockCreateMeetingWorkspace.mockResolvedValue({
|
||||
id: MEETING_ID,
|
||||
title: 'Review',
|
||||
})
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Review' },
|
||||
rationale: 'Create a meeting workspace.',
|
||||
}))
|
||||
const outcome = await executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Create a review meeting',
|
||||
action,
|
||||
confirmed: true,
|
||||
nowMs: 1_777_000_000_000,
|
||||
})
|
||||
|
||||
expect(mockCreateMeetingWorkspace).toHaveBeenCalledWith(USER_ID, 'Review')
|
||||
expect(outcome).toMatchObject({
|
||||
historyRecorded: true,
|
||||
result: { kind: 'meeting-created', meetingId: MEETING_ID, title: 'Review' },
|
||||
record: { status: 'succeeded', type: 'create_meeting' },
|
||||
})
|
||||
await expect(listActionHistory(USER_ID)).resolves.toEqual([
|
||||
expect.objectContaining({ status: 'succeeded', type: 'create_meeting' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('executes knowledge search through the server contract and records only the count', async () => {
|
||||
mockSearchKnowledge.mockResolvedValue([{
|
||||
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
documentId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
chunkIndex: 0,
|
||||
content: 'Private knowledge result',
|
||||
similarity: 0.91,
|
||||
}])
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'search_knowledge',
|
||||
args: { query: 'release policy' },
|
||||
rationale: 'Search is read only.',
|
||||
}))
|
||||
const outcome = await executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Search release policy',
|
||||
action,
|
||||
confirmed: true,
|
||||
nowMs: 1_777_000_000_001,
|
||||
})
|
||||
expect(outcome.result).toMatchObject({ kind: 'knowledge-searched' })
|
||||
expect(outcome.record.result).toEqual({
|
||||
kind: 'knowledge-searched',
|
||||
query: 'release policy',
|
||||
result_count: 1,
|
||||
})
|
||||
expect(JSON.stringify(outcome.record.result)).not.toContain('Private knowledge result')
|
||||
})
|
||||
|
||||
it('persists a failed memo execution without reporting success', async () => {
|
||||
mockGetMeetingDetail.mockRejectedValue(new Error('server refused meeting lookup'))
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'create_memo',
|
||||
args: { meeting_id: MEETING_ID, content: 'Decision recorded' },
|
||||
rationale: 'Save the decision to this meeting.',
|
||||
}))
|
||||
|
||||
await expect(executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Save the decision',
|
||||
action,
|
||||
confirmed: true,
|
||||
nowMs: 1_777_000_000_002,
|
||||
})).rejects.toMatchObject({ code: 'SERVER_ERROR', historyRecorded: true })
|
||||
await expect(listActionHistory(USER_ID)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
type: 'create_memo',
|
||||
result: null,
|
||||
errorCode: 'SERVER_ERROR',
|
||||
}),
|
||||
])
|
||||
expect(mockCreateMeetingMemo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a completed side effect with an explicit history failure instead of inviting a duplicate retry', async () => {
|
||||
mockCreateMeetingWorkspace.mockResolvedValue({ id: MEETING_ID, title: 'Review' })
|
||||
jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('disk unavailable'))
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'create_meeting',
|
||||
args: { title: 'Review' },
|
||||
rationale: 'Create a meeting workspace.',
|
||||
}))
|
||||
await expect(executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Create a review meeting',
|
||||
action,
|
||||
confirmed: true,
|
||||
nowMs: 1_777_000_000_003,
|
||||
})).resolves.toMatchObject({
|
||||
historyRecorded: false,
|
||||
result: { kind: 'meeting-created', meetingId: MEETING_ID },
|
||||
})
|
||||
expect(mockCreateMeetingWorkspace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('creates a memo only after loading the RLS-visible meeting', async () => {
|
||||
const meeting = { id: MEETING_ID, started_at: '2026-08-21T00:00:00.000Z' }
|
||||
mockGetMeetingDetail.mockResolvedValue({ meeting })
|
||||
mockCreateMeetingMemo.mockResolvedValue({
|
||||
id: MEMO_ID,
|
||||
meeting_id: MEETING_ID,
|
||||
content: 'Decision recorded',
|
||||
})
|
||||
const action = parseActionJson(JSON.stringify({
|
||||
type: 'create_memo',
|
||||
args: { meeting_id: MEETING_ID, content: 'Decision recorded' },
|
||||
rationale: 'Save the decision to this meeting.',
|
||||
}))
|
||||
await expect(executeConfirmedAction({
|
||||
userId: USER_ID,
|
||||
accessToken: 'access-token',
|
||||
input: 'Save the decision',
|
||||
action,
|
||||
confirmed: true,
|
||||
nowMs: 1_777_000_000_004,
|
||||
})).resolves.toMatchObject({
|
||||
result: { kind: 'memo-created', memoId: MEMO_ID },
|
||||
})
|
||||
expect(mockGetMeetingDetail).toHaveBeenCalledWith(USER_ID, MEETING_ID)
|
||||
expect(mockCreateMeetingMemo).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
meeting,
|
||||
'Decision recorded',
|
||||
1_777_000_000_004,
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue