@things-factory/ai-assistant 10.1.8 → 10.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/client/components/assistant-session-toolbar.ts +70 -0
  2. package/client/components/board-ai-chat.ts +50 -41
  3. package/client/index.ts +4 -0
  4. package/client/utils/assistant-request-context.ts +21 -0
  5. package/client/utils/assistant-session-controller.ts +166 -0
  6. package/client/utils/assistant-session-transport.ts +34 -0
  7. package/dist-client/components/assistant-session-toolbar.d.ts +18 -0
  8. package/dist-client/components/assistant-session-toolbar.js +109 -0
  9. package/dist-client/components/assistant-session-toolbar.js.map +1 -0
  10. package/dist-client/components/board-ai-chat.d.ts +6 -0
  11. package/dist-client/components/board-ai-chat.js +62 -40
  12. package/dist-client/components/board-ai-chat.js.map +1 -1
  13. package/dist-client/index.d.ts +4 -0
  14. package/dist-client/index.js +4 -0
  15. package/dist-client/index.js.map +1 -1
  16. package/dist-client/tsconfig.tsbuildinfo +1 -1
  17. package/dist-client/utils/assistant-request-context.d.ts +10 -0
  18. package/dist-client/utils/assistant-request-context.js +20 -0
  19. package/dist-client/utils/assistant-request-context.js.map +1 -0
  20. package/dist-client/utils/assistant-session-controller.d.ts +49 -0
  21. package/dist-client/utils/assistant-session-controller.js +165 -0
  22. package/dist-client/utils/assistant-session-controller.js.map +1 -0
  23. package/dist-client/utils/assistant-session-transport.d.ts +3 -0
  24. package/dist-client/utils/assistant-session-transport.js +39 -0
  25. package/dist-client/utils/assistant-session-transport.js.map +1 -0
  26. package/dist-server/service/assistant-chat-resolver.js +8 -0
  27. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  28. package/dist-server/service/chat-session/chat-session.js +1 -1
  29. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  30. package/dist-server/service/chat-session/session-anchor.d.ts +1 -1
  31. package/dist-server/service/chat-session/session-anchor.js +3 -1
  32. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  33. package/dist-server/service/chat-session/station-session-guard.d.ts +12 -0
  34. package/dist-server/service/chat-session/station-session-guard.js +20 -0
  35. package/dist-server/service/chat-session/station-session-guard.js.map +1 -0
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/docs/session-controller.md +52 -0
  38. package/package.json +2 -2
  39. package/server/service/assistant-chat-resolver.ts +9 -0
  40. package/server/service/chat-session/chat-session.ts +1 -1
  41. package/server/service/chat-session/session-anchor.ts +3 -1
  42. package/server/service/chat-session/station-session-guard.ts +17 -0
  43. package/server/service/session-anchor.test.ts +11 -0
  44. package/server/service/station-session-guard.test.ts +24 -0
  45. package/test/assistant-request-context.test.ts +30 -0
  46. package/test/assistant-session-controller.test.ts +88 -0
  47. package/test/assistant-session-toolbar.test.ts +29 -0
  48. package/translations/en.json +6 -0
  49. package/translations/ko.json +6 -0
@@ -0,0 +1,52 @@
1
+ # Shared assistant session lifecycle
2
+
3
+ Figure and Plant use `AssistantSessionController` and `assistantSessionTransport` from the client entry point. `ox-assistant-session-toolbar` supplies history selection, reset, loading/error status and retry controls. Existing shared Board chat uses the same toolbar while forwarding the existing `session-switch` and `session-create` events; its collaboration-specific server adapter is not replaced by the private inbox transport.
4
+
5
+ ## Responsibilities
6
+
7
+ ### Lazy creation (Figure/Plant)
8
+
9
+ Pass `{lazy:true}` as the controller's fourth constructor argument. Opening an empty inbox returns a temporary conversation without writing. Reset clears the active ID and preserves history; repeated reset stays temporary. A draft selection is remembered across reloads. Use `prepareSession(adopt)` only immediately before a real message send. It creates the session and invokes `adopt(id)` before publishing the ID to the host.
10
+
11
+ Shared chat exposes a `prepareSession` property. Its send path waits for creation, preserves the input on failure, and adopts the first ID without clearing pending input or loading empty history over the first message. Hosts must retain the same chat element during this promotion; do not key-remount it when the initial ID arrives. Actual target/session switches must still replace or clear the chat. Legacy consumers without lazy mode retain their previous lifecycle.
12
+
13
+ Session creation and message persistence are separate requests: a network failure between them can still leave a recoverable empty session. This is not an atomic guarantee. Existing empty sessions are not deleted automatically.
14
+
15
+ The framework lists/resumes sessions, creates only after a successful empty inbox, remembers selection, starts new conversations without deleting old ones, and rejects stale UI updates. A target comprises authenticated user/domain `scope`, `anchorType`, and `anchorId`. Optional browser session storage remembers selection across reloads. Storage contains session IDs, not messages; server authorization remains authoritative.
16
+
17
+ The product adapter supplies the target, current editing context, labels, tools and proposal application. Figure uses figure/application anchors; Plant retains board/domain anchors. The controller does not navigate, modify models, or choose product tools. An undefined target is a temporary conversation; consumers recreate its chat view when reset increments `revision`.
18
+
19
+ ## Flow and failure handling
20
+
21
+ Host target → controller.open → authorized inbox → existing selection or create on empty → shared chat sessionId.
22
+
23
+ Same-target pending loads coalesce. A late response cannot overwrite the active target; an abandoned empty read cannot trigger creation. A failed list never becomes an empty list. Reset failure preserves the previous session. Call `clear()` at identity teardown; scope must include account and domain, not only the document ID.
24
+
25
+ Reset in lazy mode selects a temporary conversation; its first send creates a session. Legacy non-lazy mode creates immediately. Old history remains selectable. Only the selected session history is sent in subsequent requests, while system instructions, tools and current model context still incur normal token costs. Reset does not cancel an already-submitted request or delete its server messages.
26
+
27
+ ## Limits and follow-up
28
+
29
+ The default inbox reads up to 100 sessions. Selection outside that window needs a paginated/history-search UI before claiming unlimited restoration. Browser storage is optional and tab-local; unavailable storage falls back to memory. Multi-tab initial empty creation is not server-idempotent. Full legacy Board lifecycle migration needs a collaboration-aware transport (including its existing idempotent initial-session API), not the private `mineOnly` inbox used by Figure and Plant.
30
+
31
+ ## Shared toolbar contract
32
+
33
+ Properties: `sessions`, `sessionId`, `loading`, `error`, `temporary`, `disabled`.
34
+ Events: `assistant-session-select` with `{sessionId}`, `assistant-session-reset`, `assistant-session-retry`. Events bubble across shadow roots. The toolbar performs no API mutations. Busy state disables controls; temporary state hides persisted history selection. It never deletes document data or history.
35
+
36
+ Opt-in `historyEnabled` adds an `assistant-session-history` event button and limits visible tabs to `maxTabs` (default 5), retaining the selected older conversation. The host owns its authorized, paginated history UI; this button does not implement unlimited history retrieval.
37
+
38
+ ## Send-time context and proposal safety
39
+
40
+ Shared chat accepts synchronous `contextProvider(): {systemPrompt?, hostContext?}` on every send. Throwing blocks the message and retains input. `boardProvider` independently supplies the live model. Both values are cloned before sending. Lazy session creation currently precedes this validation and may leave an empty session if validation fails.
41
+
42
+ `autoAskRequestId` distinguishes repeated identical user requests; generate it per user submission, not per render. Without it, legacy text-based deduplication remains.
43
+
44
+ `board-edit-patch` and `board-action-execute` include local `detail.requestBase = {version: 1, sessionId, hostContext, modelFingerprint}`. `assistantModelFingerprint` returns a deterministic JSON signature, not a security hash. Undefined models have no signature. Hosts must validate target identity and compare the current model signature before applying a proposal. This metadata is not persisted into restored history; missing metadata is not proof that a historical proposal is safe. The framework captures the evidence but does not implement product-specific application guards.
45
+
46
+ Figure browser verification after toolbar migration: selected old session, reset to session 3, observed sessions 1 and 2 retained; screenshot confirmed aligned controls. Models were not edited. Plant runtime validation is owned by its development task. Legacy Board forwarding has source-contract coverage, not full Board browser coverage.
47
+
48
+ ## Verification
49
+
50
+ `node --experimental-strip-types --test packages/ai-assistant/test/assistant-session-controller.test.ts`
51
+
52
+ Tests cover load failure/retry, coalescing, late target responses, reset failure, preserved history, reload selection, scope separation and disposal. Browser verification is still required for each host's routing and document-context adapter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.8",
3
+ "version": "10.1.13",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "c402982e29aab3b49e56bea4e41948be6a749c7f"
47
+ "gitHead": "f2cd3d27e32e71cca1d3af5499ef54249b1cc111"
48
48
  }
@@ -31,6 +31,7 @@ import { getRepository } from '@things-factory/shell'
31
31
  import { createAIClient, getDefaultAIClient, registryAssistantChat, type AIClient } from '@things-factory/ai-client-base'
32
32
 
33
33
  import { ChatSession } from './chat-session/chat-session.js'
34
+ import { assertStationSession } from './chat-session/station-session-guard.js'
34
35
  import { ChatMessage } from './chat-message/chat-message.js'
35
36
  import { buildLlmHistory } from './chat-message/llm-history.js'
36
37
  import { publishChatMessage } from './chat-message/chat-message-publish.js'
@@ -200,9 +201,17 @@ export class AssistantChatResolver {
200
201
  if (input.sessionId) {
201
202
  const found = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } as any })
202
203
  if (!found) throw new Error(`ChatSession ${input.sessionId} not found`)
204
+ if (found.anchorType === 'figure' && input.hostContext?.figureId !== found.anchorId) {
205
+ throw new Error('이 대화는 다른 Figure의 이력입니다. 현재 모델의 대화를 다시 여세요.')
206
+ }
207
+ if (found.anchorType === 'application' && (input.hostContext?.app !== found.anchorId || input.hostContext?.figureId)) {
208
+ throw new Error('애플리케이션 대화와 모델 대화를 구분해서 다시 여세요.')
209
+ }
203
210
  session = found
204
211
  }
205
212
 
213
+ assertStationSession(session, input.hostContext)
214
+
206
215
  const lastUserMessage = input.messages[input.messages.length - 1]
207
216
  let userMessageId: string | undefined
208
217
 
@@ -80,7 +80,7 @@ export class ChatSession {
80
80
  @Field({
81
81
  nullable: true,
82
82
  description:
83
- "What this conversation is anchored to: 'board' (authoring a board) | 'space' (a site and the twins running there) | 'instance' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
83
+ "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
84
84
  })
85
85
  anchorType?: string
86
86
 
@@ -52,7 +52,9 @@
52
52
  * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
53
53
  * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
54
54
  */
55
- export const SESSION_ANCHOR_TYPES = ['board', 'space', 'instance', 'domain', 'none'] as const
55
+ // Figure-specific history requested explicitly: reuse the existing varchar
56
+ // anchor columns, without writing Figure ids into the legacy boardId column.
57
+ export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const
56
58
 
57
59
  export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]
58
60
 
@@ -0,0 +1,17 @@
1
+ /** Station authoring must never append its design snapshot to another subject's history.
2
+ * This is a consistency check, not authorization; tenant/privilege checks stay in the resolver.
3
+ * Older Board sessions have only boardId, so preserve that supported read shape.
4
+ */
5
+ export function assertStationSession(
6
+ session: {anchorType?:string|null;anchorId?:string|null;boardId?:string|null}|undefined,
7
+ host: {surface?:string;boardId?:unknown}|undefined
8
+ ) {
9
+ if(host?.surface!=='station-screen-modeller')return
10
+ const boardId=host.boardId
11
+ const legacy=Boolean(session&&!session.anchorType&&session.boardId)
12
+ const valid=typeof boardId==='string'&&boardId.length>0&&session&&
13
+ (session.anchorType==='board'||legacy)&&
14
+ (session.anchorId||session.boardId)===boardId&&
15
+ (!session.boardId||session.boardId===boardId)
16
+ if(!valid)throw new Error('현재 Station Screen과 대화 이력이 일치하지 않습니다. 해당 화면의 대화를 다시 여세요.')
17
+ }
@@ -108,6 +108,17 @@ describe('anchor types are checked, not cast', () => {
108
108
  })
109
109
 
110
110
  describe('앵커 조회 조건 (anchorWhere)', () => {
111
+ it('애플리케이션 대화는 모델 및 다른 제품·도메인과 분리한다', () => {
112
+ expect(anchorColumns('application', 'operato-figure')).toEqual({ anchorType: 'application', anchorId: 'operato-figure', boardId: undefined })
113
+ expect(anchorWhere('D1', 'application', 'operato-figure')).not.toEqual(anchorWhere('D1', 'figure', 'F1'))
114
+ expect(anchorWhere('D1', 'application', 'operato-figure')).not.toEqual(anchorWhere('D1', 'application', 'operato-plant'))
115
+ })
116
+ it('Figure 이력은 같은 id의 Board 및 다른 도메인과 분리한다', () => {
117
+ expect(anchorColumns('figure', 'F1')).toEqual({ anchorType: 'figure', anchorId: 'F1', boardId: undefined })
118
+ expect(anchorWhere('D1', 'figure', 'F1')).toEqual({ anchorType: 'figure', anchorId: 'F1', domain: { id: 'D1' } })
119
+ expect(anchorWhere('D2', 'figure', 'F1')).not.toEqual(anchorWhere('D1', 'figure', 'F1'))
120
+ expect(() => anchorColumns('figure', '')).toThrow()
121
+ })
111
122
  it('board 앵커는 boardId 로 찾는다 — 앵커 컬럼이 빈 옛 행도 잡힌다', () => {
112
123
  /* 신규 행은 dual-write 로 boardId 도 채워지므로 같은 조건에 함께 걸린다. */
113
124
  expect(anchorWhere('dom-1', 'board', 'B1')).toEqual({ boardId: 'B1', domain: { id: 'dom-1' } })
@@ -0,0 +1,24 @@
1
+ import { assertStationSession } from './chat-session/station-session-guard'
2
+
3
+ describe('Station authoring session boundary',()=>{
4
+ const host={surface:'station-screen-modeller',boardId:'A'}
5
+ it('accepts matching Board and legacy Board sessions',()=>{
6
+ expect(()=>assertStationSession({anchorType:'board',anchorId:'A',boardId:'A'},host)).not.toThrow()
7
+ expect(()=>assertStationSession({boardId:'A'},host)).not.toThrow()
8
+ })
9
+ it('rejects a different Board or inconsistent dual-write fields',()=>{
10
+ expect(()=>assertStationSession({anchorType:'board',anchorId:'B'},host)).toThrow(/일치/)
11
+ expect(()=>assertStationSession({anchorType:'board',anchorId:'A',boardId:'B'},host)).toThrow(/일치/)
12
+ })
13
+ it('rejects missing session, missing Board id and other anchor types',()=>{
14
+ expect(()=>assertStationSession(undefined,host)).toThrow(/일치/)
15
+ expect(()=>assertStationSession({anchorType:'board',anchorId:'A'},{surface:host.surface})).toThrow(/일치/)
16
+ for(const anchorType of ['domain','application','figure','none']){
17
+ expect(()=>assertStationSession({anchorType,anchorId:'A'},host)).toThrow(/일치/)
18
+ }
19
+ })
20
+ it('does not change other consumers of assistantChat',()=>{
21
+ expect(()=>assertStationSession(undefined,undefined)).not.toThrow()
22
+ expect(()=>assertStationSession({anchorType:'domain',anchorId:'D'},{surface:'plant-operations'})).not.toThrow()
23
+ })
24
+ })
@@ -0,0 +1,30 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { readFileSync } from 'node:fs'
4
+ import { assistantAutoAskKey, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
5
+
6
+ test('model signature ignores object key order but detects nested edits and array order', () => {
7
+ assert.equal(assistantModelFingerprint({ b: 2, a: { y: 3, x: 1 } }), assistantModelFingerprint({ a: { x: 1, y: 3 }, b: 2 }))
8
+ assert.notEqual(assistantModelFingerprint({ parts: [{ x: 1 }] }), assistantModelFingerprint({ parts: [{ x: 2 }] }))
9
+ assert.notEqual(assistantModelFingerprint([1, 2]), assistantModelFingerprint([2, 1]))
10
+ assert.equal(assistantModelFingerprint(undefined), undefined)
11
+ })
12
+ test('identical text with a new request ID is a new send', () => {
13
+ assert.equal(assistantAutoAskKey('hello'), 'hello')
14
+ assert.equal(assistantAutoAskKey('hello', 'one'), assistantAutoAskKey('hello', 'one'))
15
+ assert.notEqual(assistantAutoAskKey('hello', 'one'), assistantAutoAskKey('hello', 'two'))
16
+ })
17
+ test('recent tabs retain the selected older conversation without mutating the inbox', () => {
18
+ const sessions = ['a', 'b', 'c', 'd'].map(id => ({ id }))
19
+ assert.deepEqual(assistantRecentSessions(sessions, 'd', 3).map(s => s.id), ['a', 'b', 'd'])
20
+ assert.deepEqual(sessions.map(s => s.id), ['a', 'b', 'c', 'd'])
21
+ assert.deepEqual(assistantRecentSessions([], '', 5), [])
22
+ })
23
+ test('send captures current context before optimistic input and attaches request base to edit events', () => {
24
+ const chat = readFileSync(new URL('../client/components/board-ai-chat.ts', import.meta.url), 'utf8')
25
+ const send = chat.slice(chat.indexOf('private async send()'))
26
+ assert.ok(send.indexOf('this.contextProvider?.()') < send.indexOf('// optimistic'))
27
+ assert.match(send, /modelFingerprint: assistantModelFingerprint\(liveBoard\)/)
28
+ assert.match(send, /detail: \{ actions: out.actions, sessionId: out.sessionId, requestBase \}/)
29
+ assert.match(send, /requestContext\.hostContext/)
30
+ })
@@ -0,0 +1,88 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import { AssistantSessionController, type AssistantSession, type AssistantSessionTarget } from '../client/utils/assistant-session-controller.ts'
4
+
5
+ const target = (id: string, scope = 'user:domain'): AssistantSessionTarget => ({ scope, anchorType: 'figure', anchorId: id })
6
+ test('lazy open/reset never persist; first send creates once and adopts before publish', async () => {
7
+ let creates = 0, adopted = ''
8
+ const c = new AssistantSessionController({ list: async () => [], create: async () => ({ id: `s${++creates}` }) }, state => {
9
+ if (state.sessionId) assert.equal(adopted, state.sessionId)
10
+ }, undefined, { lazy: true })
11
+ await c.open(target('new')); await c.reset(); await c.reset()
12
+ assert.equal(creates, 0); assert.equal(c.state.sessionId, '')
13
+ await c.prepareSession(id => { adopted = id })
14
+ assert.equal(creates, 1); assert.equal(c.state.sessionId, 's1')
15
+ await c.reset(); assert.equal(creates, 1); assert.equal(c.state.sessions.length, 1)
16
+ })
17
+ test('failed first-send creation preserves temporary state and can retry', async () => {
18
+ let fail = true
19
+ const c = new AssistantSessionController({ list: async () => [], create: async () => { if (fail) throw Error('offline'); return { id:'ok' } } }, () => {}, undefined, { lazy:true })
20
+ await c.open(target('new')); await assert.rejects(c.prepareSession(), /offline/)
21
+ assert.equal(c.state.sessionId, ''); fail = false
22
+ assert.equal(await c.prepareSession(), 'ok')
23
+ })
24
+ test('empty conversation is reused on repeated reset', async () => {
25
+ let creates = 0
26
+ const c = new AssistantSessionController({ list: async () => [{ id:'empty' }], isEmpty: async () => true,
27
+ create: async () => ({ id: String(++creates) }) }, () => {})
28
+ await c.open(target('a')); await c.reset(); await c.reset()
29
+ assert.equal(creates, 0); assert.equal(c.state.sessionId, 'empty')
30
+ })
31
+ const deferred = <T>() => { let resolve!: (value: T) => void; const promise = new Promise<T>(r => { resolve = r }); return { promise, resolve } }
32
+ test('empty successful inbox creates once; repeated same-target loads coalesce', async () => {
33
+ const d = deferred<AssistantSession[]>()
34
+ let creates = 0, lists = 0
35
+ const c = new AssistantSessionController({ list: () => { lists++; return d.promise }, create: async () => ({ id: `new${++creates}` }) }, () => {})
36
+ const first = c.open(target('a')); const second = c.open(target('a'))
37
+ d.resolve([]); await Promise.all([first, second])
38
+ assert.equal(lists, 1); assert.equal(creates, 1); assert.equal(c.state.sessionId, 'new1')
39
+ })
40
+ test('failed inbox never creates a replacement; retry recovers', async () => {
41
+ let fail = true, creates = 0
42
+ const c = new AssistantSessionController({ list: async () => { if (fail) throw Error('denied'); return [{ id: 'old' }] }, create: async () => ({ id: `${++creates}` }) }, () => {})
43
+ await c.open(target('a')); assert.equal(c.state.error, 'denied'); assert.equal(creates, 0)
44
+ fail = false; await c.open(target('a'), true); assert.equal(c.state.sessionId, 'old')
45
+ })
46
+ test('late response cannot replace current target and returning restores selected history', async () => {
47
+ const d = deferred<AssistantSession[]>()
48
+ const c = new AssistantSessionController({ list: t => t.anchorId === 'a' ? d.promise : Promise.resolve([{ id: 'b1' }, { id: 'b2' }]), create: async () => ({ id: 'new' }) }, () => {})
49
+ const first = c.open(target('a')); await c.open(target('b')); c.select('b2')
50
+ d.resolve([{ id: 'a1' }]); await first; assert.equal(c.state.sessionId, 'b2')
51
+ await c.open(target('a')); await c.open(target('b')); assert.equal(c.state.sessionId, 'b2')
52
+ assert.equal(c.select('a1'), false)
53
+ })
54
+ test('reset preserves history, changes ID and restores empty new session after reload', async () => {
55
+ const values = new Map<string, string>()
56
+ const storage = { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => { values.set(key, value) } }
57
+ const transport = { list: async () => [{ id: 'old' }, { id: 'new' }], create: async () => ({ id: 'new' }) }
58
+ const c = new AssistantSessionController(transport, () => {}, storage)
59
+ await c.open(target('a')); await c.reset()
60
+ assert.equal(c.state.sessionId, 'new'); assert.ok(c.state.sessions.some(s => s.id === 'old'))
61
+ const reloaded = new AssistantSessionController(transport, () => {}, storage)
62
+ await reloaded.open(target('a')); assert.equal(reloaded.state.sessionId, 'new')
63
+ await reloaded.open(target('a', 'other-user:domain')); assert.equal(reloaded.state.sessionId, 'old')
64
+ })
65
+ test('late reset cannot overwrite another target', async () => {
66
+ const d = deferred<AssistantSession>()
67
+ const c = new AssistantSessionController({ list: async t => [{ id: t.anchorId }], create: () => d.promise }, () => {})
68
+ await c.open(target('a')); const reset = c.reset(); await c.open(target('b'))
69
+ d.resolve({ id: 'new-a' }); await reset; assert.equal(c.state.sessionId, 'b')
70
+ })
71
+ test('reset failure preserves active history; temporary reset needs no server', async () => {
72
+ const c = new AssistantSessionController({ list: async () => [{ id: 'old' }], create: async () => { throw Error('offline') } }, () => {})
73
+ await c.open(target('a')); await c.reset(); assert.equal(c.state.sessionId, 'old'); assert.equal(c.state.error, 'offline')
74
+ await c.open(); await c.reset(); assert.equal(c.state.revision, 1); assert.equal(c.state.error, '')
75
+ })
76
+ test('clear invalidates pending loads', async () => {
77
+ const d = deferred<AssistantSession[]>()
78
+ const c = new AssistantSessionController({ list: () => d.promise, create: async () => ({ id: 'new' }) }, () => {})
79
+ const load = c.open(target('a')); c.clear(); d.resolve([{ id: 'old' }]); await load
80
+ assert.equal(c.state.sessionId, ''); assert.equal(c.state.target, undefined)
81
+ })
82
+ test('abandoned empty inbox cannot create under the next domain', async () => {
83
+ const d = deferred<AssistantSession[]>()
84
+ let creates = 0
85
+ const c = new AssistantSessionController({ list: () => d.promise, create: async () => ({ id: `${++creates}` }) }, () => {})
86
+ const load = c.open(target('a')); c.clear(); d.resolve([]); await load
87
+ assert.equal(creates, 0); assert.equal(c.state.error, '')
88
+ })
@@ -0,0 +1,29 @@
1
+ import assert from 'node:assert/strict'
2
+ import { readFileSync } from 'node:fs'
3
+ import { test } from 'node:test'
4
+ const read = (file: string) => readFileSync(new URL(file, import.meta.url), 'utf8')
5
+ const toolbar = read('../client/components/assistant-session-toolbar.ts')
6
+ test('toolbar emits domain-neutral intent and never calls persistence APIs', () => {
7
+ for (const event of ['assistant-session-select', 'assistant-session-reset', 'assistant-session-retry']) assert.ok(toolbar.includes(event))
8
+ assert.doesNotMatch(toolbar, /client\.mutate|createAISession|createChatSession/)
9
+ assert.match(toolbar, /bubbles: true, composed: true/)
10
+ })
11
+ test('busy/error/temporary states and accessible controls are explicit', () => {
12
+ assert.match(toolbar, /this\.disabled \|\| this\.loading \|\| !!this\.error/)
13
+ assert.match(toolbar, /this\.temporary \? nothing/)
14
+ assert.match(toolbar, /aria-label=/)
15
+ assert.match(toolbar, /role="tablist"/)
16
+ assert.match(toolbar, /role="tab"/)
17
+ assert.doesNotMatch(toolbar, /<select/)
18
+ assert.match(toolbar, /role="alert"/)
19
+ assert.match(toolbar, /role="status"/)
20
+ })
21
+ test('legacy Board chat forwards shared controls through existing host events', () => {
22
+ const chat = read('../client/components/board-ai-chat.ts')
23
+ assert.match(chat, /import '\.\/assistant-session-toolbar\.js'/)
24
+ assert.match(chat, /<ox-assistant-session-toolbar/)
25
+ assert.match(chat, /event\.stopPropagation\(\); this\._onSessionTabClick/)
26
+ assert.match(chat, /event\.stopPropagation\(\); this\._onSessionCreateClick/)
27
+ assert.match(chat, /new CustomEvent\('session-switch'/)
28
+ assert.match(chat, /new CustomEvent\('session-create'/)
29
+ })
@@ -1,4 +1,10 @@
1
1
  {
2
+ "ai-assistant.session.new": "New conversation",
3
+ "ai-assistant.session.history": "Conversation history",
4
+ "ai-assistant.session.reset": "New conversation",
5
+ "ai-assistant.session.reset-help": "Keep previous history and your work, and start a new conversation. Previous messages are excluded from subsequent requests.",
6
+ "ai-assistant.session.loading": "Loading conversation history…",
7
+ "ai-assistant.session.retry": "Retry",
2
8
  "ai-assistant.label.ai": "operato",
3
9
  "ai-assistant.label.participant": "participant",
4
10
  "ai-assistant.label.create": "Create",
@@ -1,4 +1,10 @@
1
1
  {
2
+ "ai-assistant.session.new": "새 대화",
3
+ "ai-assistant.session.history": "대화 이력",
4
+ "ai-assistant.session.reset": "대화 리셋",
5
+ "ai-assistant.session.reset-help": "이전 이력과 작업 내용은 보존하고 새 대화를 시작합니다. 이전 대화는 다음 요청에 포함하지 않습니다.",
6
+ "ai-assistant.session.loading": "대화 이력을 불러오는 중…",
7
+ "ai-assistant.session.retry": "다시 시도",
2
8
  "ai-assistant.label.ai": "operato",
3
9
  "ai-assistant.label.participant": "참여자",
4
10
  "ai-assistant.label.create": "생성",