11import { LinkCodeClient } from '@linkcode/client-core' ;
2- import { ATTACHMENT_UPLOAD_CHUNK_BYTES , AttachmentIdSchema } from '@linkcode/schema' ;
2+ import type { AttachmentId , SessionId } from '@linkcode/schema' ;
3+ import {
4+ ATTACHMENT_UPLOAD_CHUNK_BYTES ,
5+ AttachmentIdSchema ,
6+ OperationIdSchema ,
7+ } from '@linkcode/schema' ;
8+ import type { Transport } from '@linkcode/transport' ;
9+ import { createWireMessage } from '@linkcode/transport' ;
10+ import { nullthrow } from 'foxts/guard' ;
311import { describe , expect , it } from 'vitest' ;
412import { createDevMockTransport } from '../../src/mock/dev-mock-transport' ;
513
@@ -9,26 +17,101 @@ async function connectedClient(): Promise<LinkCodeClient> {
917 return client ;
1018}
1119
20+ /** `turn.submit` has no client-core method yet (CODE-638), so the prompt-ref root is driven raw. */
21+ function submitPromptRef (
22+ transport : Transport ,
23+ sessionId : SessionId ,
24+ attachmentId : AttachmentId ,
25+ ) : Promise < void > {
26+ return new Promise ( ( resolve , reject ) => {
27+ const clientReqId = 'creq-attachment-ref' ;
28+ const unsubscribe = transport . onMessage ( ( message ) => {
29+ const p = message . payload ;
30+ if ( ! ( 'replyTo' in p ) || p . replyTo !== clientReqId ) return ;
31+ unsubscribe ( ) ;
32+ if ( p . kind === 'turn.submitted' ) resolve ( ) ;
33+ else reject ( new Error ( p . kind === 'request.failed' ? p . message : `unexpected ${ p . kind } ` ) ) ;
34+ } ) ;
35+ transport . send (
36+ createWireMessage ( {
37+ kind : 'turn.submit' ,
38+ clientReqId,
39+ sessionId,
40+ operationId : OperationIdSchema . parse ( 'op-attachment-ref' ) ,
41+ input : {
42+ type : 'prompt' ,
43+ blocks : [
44+ { type : 'text' , text : 'look at this' } ,
45+ { type : 'attachment_ref' , attachmentId } ,
46+ ] ,
47+ } ,
48+ } ) ,
49+ ) ;
50+ } ) ;
51+ }
52+
1253describe ( 'dev mock attachment store' , ( ) => {
1354 it ( 'round-trips a multi-chunk upload and dedupes the second copy' , async ( ) => {
1455 const client = await connectedClient ( ) ;
15- const sessionId = await client . startSession ( { kind : 'codex' , cwd : '/mock/repo' } ) ;
56+ await client . startSession ( { kind : 'codex' , cwd : '/mock/repo' } ) ;
1657 const bytes = new Uint8Array ( ATTACHMENT_UPLOAD_CHUNK_BYTES + 17 ) ;
1758 for ( let i = 0 ; i < bytes . byteLength ; i ++ ) bytes [ i ] = i % 251 ;
1859
1960 const first = await client . putAttachment ( { bytes, name : 'shot.bin' , attachmentKind : 'file' } ) ;
20- const read = await client . getAttachmentBytes ( sessionId , first . attachmentId ) ;
21- expect ( read . bytes ) . toEqual ( bytes ) ;
22- expect ( read . blobId ) . toBe ( first . blobId ) ;
23-
2461 // Same bytes, new record: the mock must answer `exists` and transfer nothing.
2562 const second = await client . putAttachment ( { bytes, name : 'copy.bin' , attachmentKind : 'file' } ) ;
2663 expect ( second . blobId ) . toBe ( first . blobId ) ;
2764 expect ( second . attachmentId ) . not . toBe ( first . attachmentId ) ;
2865 client . dispose ( ) ;
2966 } ) ;
3067
31- it ( 'rejects an unknown attachment and a chunk at the wrong offset' , async ( ) => {
68+ it ( 'reads an attachment only from a session that roots it' , async ( ) => {
69+ const client = await connectedClient ( ) ;
70+ const sessionId = await client . startSession ( { kind : 'codex' , cwd : '/mock/repo' } ) ;
71+ const bytes = new TextEncoder ( ) . encode ( 'resource bytes' ) ;
72+
73+ // An upload alone is a draft lease — the daemon's isReachable roots nothing yet.
74+ const draft = await client . putAttachment ( { bytes, name : 'draft.txt' , attachmentKind : 'file' } ) ;
75+ await expect ( client . getAttachmentBytes ( sessionId , draft . attachmentId ) ) . rejects . toThrow (
76+ 'Attachment not found' ,
77+ ) ;
78+
79+ // A session resource is a root, and carries the attachment its bytes landed in.
80+ const resource = await client . uploadSource (
81+ sessionId ,
82+ 'brief.txt' ,
83+ btoa ( 'resource bytes' ) ,
84+ 'text/plain' ,
85+ ) ;
86+ const attachmentId = nullthrow ( resource . attachmentId , 'resource missing attachmentId' ) ;
87+ const read = await client . getAttachmentBytes ( sessionId , attachmentId ) ;
88+ expect ( read . bytes ) . toEqual ( bytes ) ;
89+
90+ const otherSession = await client . startSession ( { kind : 'codex' , cwd : '/mock/other' } ) ;
91+ await expect ( client . getAttachmentBytes ( otherSession , attachmentId ) ) . rejects . toThrow (
92+ 'Attachment not found' ,
93+ ) ;
94+ client . dispose ( ) ;
95+ } ) ;
96+
97+ it ( 'roots a draft attachment once a prompt of that session references it' , async ( ) => {
98+ const transport = createDevMockTransport ( ) ;
99+ const client = new LinkCodeClient ( transport ) ;
100+ await client . connect ( ) ;
101+ const sessionId = await client . startSession ( { kind : 'codex' , cwd : '/mock/repo' } ) ;
102+ const bytes = new TextEncoder ( ) . encode ( 'attached by prompt' ) ;
103+ const draft = await client . putAttachment ( { bytes, name : 'note.txt' , attachmentKind : 'file' } ) ;
104+
105+ await expect ( client . getAttachmentBytes ( sessionId , draft . attachmentId ) ) . rejects . toThrow (
106+ 'Attachment not found' ,
107+ ) ;
108+ await submitPromptRef ( transport , sessionId , draft . attachmentId ) ;
109+ const read = await client . getAttachmentBytes ( sessionId , draft . attachmentId ) ;
110+ expect ( read . bytes ) . toEqual ( bytes ) ;
111+ client . dispose ( ) ;
112+ } ) ;
113+
114+ it ( 'rejects an unknown attachment, a wrong offset, and bytes that are not the declared image' , async ( ) => {
32115 const client = await connectedClient ( ) ;
33116 const sessionId = await client . startSession ( { kind : 'codex' , cwd : '/mock/repo' } ) ;
34117 await expect (
@@ -44,6 +127,15 @@ describe('dev mock attachment store', () => {
44127 await expect ( client . sendAttachmentChunk ( begun . uploadId , 8 , 'YQ==' ) ) . rejects . toThrow (
45128 'Expected offset 0' ,
46129 ) ;
130+
131+ await expect (
132+ client . putAttachment ( {
133+ bytes : new TextEncoder ( ) . encode ( 'not a png' ) ,
134+ name : 'fake.png' ,
135+ mimeType : 'image/png' ,
136+ attachmentKind : 'image' ,
137+ } ) ,
138+ ) . rejects . toThrow ( 'File contents are not image/png' ) ;
47139 client . dispose ( ) ;
48140 } ) ;
49141} ) ;
0 commit comments