Skip to content

Commit 87e05ba

Browse files
bluedbirdclaude
andcommitted
feat: persist attachments, extract execution state, simplify provider boot
- Persist attachment metadata in message_attachments table - Move attachment rendering from request-runner to agent layer - Filter prior history by ID to prevent duplicate user messages - Extract execution state into injectable ExecutionStateStore - Add provider.kind to config, replace detectProvider with checkProvider - Remove dead detectProvider code and DETECTION_ORDER - Export schema from persistence barrel, use in tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 38b699f commit 87e05ba

16 files changed

Lines changed: 342 additions & 148 deletions

File tree

packages/agent/src/agent.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { Message, ProgressCallback, ProviderAdapter, ProviderMessage } from '@homie/core';
1+
import type {
2+
Attachment,
3+
Message,
4+
ProgressCallback,
5+
ProviderAdapter,
6+
ProviderMessage,
7+
} from '@homie/core';
28
import { createLogger } from '@homie/observability';
39

410
const log = createLogger('agent');
@@ -8,6 +14,7 @@ const SYSTEM_PROMPT = 'You are Homie. Keep responses concise (Telegram chat).';
814
export interface AgentInput {
915
sessionId: string;
1016
text: string;
17+
attachments?: Attachment[];
1118
history: Message[];
1219
/** When true, skip resume and send full history (e.g. after an interrupted run) */
1320
forceFullHistory?: boolean;
@@ -65,13 +72,28 @@ function buildMessages(input: AgentInput): ProviderMessage[] {
6572

6673
for (const msg of input.history) {
6774
if (msg.direction === 'in') {
68-
messages.push({ role: 'user', content: msg.text });
75+
messages.push({ role: 'user', content: renderUserMessage(msg.text, msg.attachments) });
6976
} else if (msg.direction === 'out') {
7077
messages.push({ role: 'assistant', content: msg.text });
7178
}
7279
}
7380

74-
messages.push({ role: 'user', content: input.text });
81+
messages.push({ role: 'user', content: renderUserMessage(input.text, input.attachments) });
7582

7683
return messages;
7784
}
85+
86+
function renderUserMessage(text: string, attachments?: Attachment[]): string {
87+
if (!attachments || attachments.length === 0) {
88+
return text;
89+
}
90+
91+
const refs = attachments.map((attachment) => {
92+
const label = attachment.fileName
93+
? `${attachment.fileName} (${attachment.mimeType})`
94+
: attachment.mimeType;
95+
return `[Attached file: ${attachment.filePath}] (${label})`;
96+
});
97+
98+
return `${refs.join('\n')}\n\n${text}`;
99+
}

packages/config/src/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const AppConfigSchema = z.object({
99
botToken: z.string().min(1, 'TELEGRAM_BOT_TOKEN is required'),
1010
}),
1111
provider: z.object({
12+
kind: z.enum(['claude-code', 'codex']).default('claude-code'),
1213
model: z.string().default(''),
1314
extraArgs: z.array(z.string()).default([]),
1415
}),

packages/core/src/interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Attachment } from './events';
12
import type { Message, Session } from './types';
23

34
// --- Channel ---
@@ -79,6 +80,7 @@ export interface SessionStore {
7980
direction: Message['direction'],
8081
text: string,
8182
rawSourceId?: string | null,
83+
attachments?: Attachment[],
8284
): Promise<Message>;
8385
listRecentMessages(sessionId: string, limit: number): Promise<Message[]>;
8486
}

packages/core/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Attachment } from './events';
2+
13
export type MessageDirection = 'in' | 'out';
24

35
export interface Session {
@@ -13,6 +15,7 @@ export interface Message {
1315
sessionId: string;
1416
direction: MessageDirection;
1517
text: string;
18+
attachments?: Attachment[];
1619
createdAt: string;
1720
rawSourceId: string | null;
1821
}

packages/gateway/src/commands.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Database } from 'bun:sqlite';
22
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
33
import { createAgent } from '@homie/agent';
44
import { AbortError, type ProviderAdapter } from '@homie/core';
5-
import { schema } from '@homie/persistence/src/migrations';
6-
import { createSessionStore } from '@homie/persistence/src/session-store';
5+
import { createSessionStore, schema } from '@homie/persistence';
76
import { type CommandContext, createCommandHandler } from './commands';
87
import { createRequestRunner } from './request-runner';
98

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Attachment, ProgressHandler, ReplyFn } from '@homie/core';
2+
3+
export interface ActiveRequestState {
4+
abort: AbortController;
5+
done: Promise<void>;
6+
text: string;
7+
startedAt: number;
8+
lastToolHint: string;
9+
}
10+
11+
export interface QueuedRequestState {
12+
channel: string;
13+
chatId: string;
14+
text: string;
15+
rawSourceId: string | null;
16+
agentType?: string | null;
17+
agentModel?: string | null;
18+
reply: ReplyFn;
19+
progress?: ProgressHandler;
20+
attachments?: Attachment[];
21+
}
22+
23+
export interface ExecutionStateStore {
24+
getActive(key: string): ActiveRequestState | undefined;
25+
setActive(key: string, active: ActiveRequestState): void;
26+
deleteActive(key: string, active?: ActiveRequestState): void;
27+
hasStarting(key: string): boolean;
28+
markStarting(key: string): void;
29+
clearStarting(key: string): void;
30+
getQueued(key: string): QueuedRequestState | undefined;
31+
setQueued(key: string, request: QueuedRequestState): void;
32+
deleteQueued(key: string): void;
33+
getLastSubmittedText(key: string): string | undefined;
34+
setLastSubmittedText(key: string, text: string): void;
35+
consumeStaleResume(key: string): boolean;
36+
markStaleResume(key: string): void;
37+
clearStaleResume(key: string): void;
38+
}
39+
40+
export function createInMemoryExecutionStateStore(): ExecutionStateStore {
41+
const activeRequests = new Map<string, ActiveRequestState>();
42+
const pendingQueue = new Map<string, QueuedRequestState>();
43+
const lastSubmittedText = new Map<string, string>();
44+
const starting = new Set<string>();
45+
const staleResume = new Set<string>();
46+
47+
return {
48+
getActive(key) {
49+
return activeRequests.get(key);
50+
},
51+
setActive(key, active) {
52+
activeRequests.set(key, active);
53+
},
54+
deleteActive(key, active) {
55+
if (!active) {
56+
activeRequests.delete(key);
57+
return;
58+
}
59+
if (activeRequests.get(key) === active) {
60+
activeRequests.delete(key);
61+
}
62+
},
63+
hasStarting(key) {
64+
return starting.has(key);
65+
},
66+
markStarting(key) {
67+
starting.add(key);
68+
},
69+
clearStarting(key) {
70+
starting.delete(key);
71+
},
72+
getQueued(key) {
73+
return pendingQueue.get(key);
74+
},
75+
setQueued(key, request) {
76+
pendingQueue.set(key, request);
77+
},
78+
deleteQueued(key) {
79+
pendingQueue.delete(key);
80+
},
81+
getLastSubmittedText(key) {
82+
return lastSubmittedText.get(key);
83+
},
84+
setLastSubmittedText(key, text) {
85+
lastSubmittedText.set(key, text);
86+
},
87+
consumeStaleResume(key) {
88+
const had = staleResume.has(key);
89+
staleResume.delete(key);
90+
return had;
91+
},
92+
markStaleResume(key) {
93+
staleResume.add(key);
94+
},
95+
clearStaleResume(key) {
96+
staleResume.delete(key);
97+
},
98+
};
99+
}

packages/gateway/src/gateway.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Database } from 'bun:sqlite';
22
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
33
import { createAgent } from '@homie/agent';
44
import type { ChatMessageEvent, CommandEvent, ProviderAdapter } from '@homie/core';
5-
import { schema } from '@homie/persistence/src/migrations';
6-
import { createSessionStore } from '@homie/persistence/src/session-store';
5+
import { createSessionStore, schema } from '@homie/persistence';
76
import { createGateway } from './gateway';
87

98
function createTestDb(): Database {

packages/gateway/src/request-runner.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Database } from 'bun:sqlite';
22
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
33
import { type Agent, createAgent } from '@homie/agent';
44
import { AbortError, type ProviderAdapter } from '@homie/core';
5-
import { schema } from '@homie/persistence/src/migrations';
6-
import { createSessionStore } from '@homie/persistence/src/session-store';
5+
import { createSessionStore, schema } from '@homie/persistence';
76
import { createRequestRunner, type RequestRunner } from './request-runner';
87

98
function createTestDb(): Database {
@@ -129,6 +128,51 @@ describe('RequestRunner', () => {
129128
expect(messages[1]?.text).toBe('done');
130129
});
131130

131+
test('passes attachment metadata through persisted history', async () => {
132+
const replies: string[] = [];
133+
await runner.submit({
134+
channel: 'telegram',
135+
chatId: 'chat1',
136+
text: 'review attached',
137+
rawSourceId: '1',
138+
attachments: [
139+
{
140+
filePath: '/tmp/spec.md',
141+
mimeType: 'text/markdown',
142+
fileName: 'spec.md',
143+
},
144+
],
145+
reply: async (text) => {
146+
replies.push(text);
147+
},
148+
});
149+
await new Promise((r) => setTimeout(r, 50));
150+
151+
const messages = await sessionStore.listRecentMessages(sessionId, 10);
152+
expect(messages[0]?.attachments).toEqual([
153+
{
154+
filePath: '/tmp/spec.md',
155+
mimeType: 'text/markdown',
156+
fileName: 'spec.md',
157+
},
158+
]);
159+
expect(replies).toContain('done');
160+
});
161+
162+
test('does not duplicate the latest user message in provider input', async () => {
163+
const { params } = submitParams('hello once');
164+
await runner.submit(params);
165+
await new Promise((r) => setTimeout(r, 50));
166+
167+
expect(provider.generate).toHaveBeenCalledTimes(1);
168+
const [input] = (provider.generate as ReturnType<typeof mock>).mock.calls[0] as [
169+
{ messages: Array<{ role: string; content: string }> },
170+
];
171+
const userMessages = input.messages.filter((message) => message.role === 'user');
172+
expect(userMessages).toHaveLength(1);
173+
expect(userMessages[0]?.content).toBe('hello once');
174+
});
175+
132176
test('replies with generic failure on provider error', async () => {
133177
(provider.generate as ReturnType<typeof mock>).mockImplementation(async () => {
134178
throw new Error('boom');

0 commit comments

Comments
 (0)