Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,12 +570,21 @@ export class DiscordAdapter extends MessagingAdapter {
return;
}

// Reset tracker state for new prompt cycle on existing sessions
// Reset tracker state and finalize any in-flight draft for existing sessions.
// Some agents (e.g. gemini) don't emit usage/tool_call events between turns,
// so a new user message is the only reliable signal that the prior turn ended.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check that this doesn't break with back to back messages

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

back to back messages are fine - anything else to be worried about here?

// Without finalizing here, streaming text from this turn appends to the prior
// message draft and the previous "💭 Still thinking..." / typing indicators
// never clear.
if (sessionId !== "unknown") {
const tracker = this.sessionTrackers.get(sessionId);
if (tracker) {
await tracker.onNewPrompt();
}
if (message.channel.isThread()) {
const isAssistant = this.assistantSession != null && sessionId === this.assistantSession.id;
await this.draftManager.finalize(sessionId, message.channel as ThreadChannel, isAssistant);
}
}

// Route to core for session dispatch
Expand Down Expand Up @@ -832,6 +841,29 @@ export class DiscordAdapter extends MessagingAdapter {
return ctx;
}

/**
* Finalize the in-flight text draft for a session. Public so the `turn:end`
* middleware can trigger it on every prompt completion — without this, agents
* that don't emit `usage`/`session_end` at turn end leave the draft stuck at
* its mid-stream truncation (~1900 chars) instead of splitting into the full
* multi-message response.
*/
async finalizeSessionDraft(sessionId: string): Promise<void> {
const session = this.core.sessionManager.getSession(sessionId);
const threadId = session?.threadId;
if (!threadId) return;
try {
const channel = this.guild.channels.cache.get(threadId)
?? await this.guild.channels.fetch(threadId).catch(() => null);
if (!channel?.isThread()) return;
const thread = channel as ThreadChannel;
const isAssistant = this.assistantSession != null && sessionId === this.assistantSession.id;
await this.draftManager.finalize(sessionId, thread, isAssistant);
} catch (err) {
log.warn({ err, sessionId }, "[DiscordAdapter] finalizeSessionDraft failed");
}
}

// ─── sendMessage ──────────────────────────────────────────────────────────

async sendMessage(
Expand Down Expand Up @@ -884,6 +916,24 @@ export class DiscordAdapter extends MessagingAdapter {
const draft = this.draftManager.getOrCreate(sessionId, thread);
draft.append(content.text);
this.draftManager.appendText(sessionId, content.text);

// Gemini-acp emits chain-of-thought as inline text and signals the end of
// the thought block with `[Thought: true]`. Everything BEFORE the marker
// is the thought; everything AFTER is the response. At medium/low we hide
// the thought by retroactively trimming the draft to only the post-marker
// content. At high we keep everything visible.
const verbosity = this.resolveMode(sessionId);
if (verbosity !== "high") {
const buffer = draft.getBuffer();
const marker = "[Thought: true]";
const idx = buffer.lastIndexOf(marker);
if (idx >= 0) {
const postMarker = buffer.slice(idx + marker.length).replace(/^\s+/, "");
if (postMarker !== buffer) {
draft.replaceBuffer(postMarker);
}
}
}
}

protected async handleToolCall(sessionId: string, content: OutgoingMessage, _verbosity: DisplayVerbosity): Promise<void> {
Expand Down
53 changes: 51 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { OpenACPPlugin, InstallContext, OpenACPCore } from '@openacp/plugin-sdk'
import type { DiscordChannelConfig } from './types.js'
import type { DiscordAdapter } from './adapter.js'

function createDiscordPlugin(): OpenACPPlugin {
let adapter: { stop(): Promise<void> } | null = null
let adapter: DiscordAdapter | null = null

return {
name: '@openacp/discord-adapter',
Expand All @@ -16,7 +17,7 @@ function createDiscordPlugin(): OpenACPPlugin {
optionalPluginDependencies: {
'@openacp/speech': '^1.0.0',
},
permissions: ['services:register', 'kernel:access', 'events:read'],
permissions: ['services:register', 'kernel:access', 'events:read', 'middleware:register'],

async install(ctx: InstallContext) {
const { terminal, settings } = ctx
Expand Down Expand Up @@ -181,6 +182,54 @@ function createDiscordPlugin(): OpenACPPlugin {

ctx.registerService('adapter:discord', adapter)
ctx.log.info('Discord adapter registered')

// Inject Discord rendering rules into the first prompt of every new
// Discord session. Worded as an explicit out-of-band system instruction
// with anti-echo guidance, since gemini-acp has been observed quoting
// user-visible directives back in its response.
ctx.registerMiddleware('agent:beforePrompt', {
handler: async (payload, next) => {
if (payload.sourceAdapterId !== 'discord') return next()
const session = core.sessionManager.getSession(payload.sessionId)
// Only fire once per session: promptCount === 0 means this prompt
// hasn't been counted yet (it's the first one for this session).
if (!session || session.promptCount !== 0) return next()

payload.text =
"<system_instruction>\n" +
"Constraint for response formatting on Discord:\n" +
"- Do NOT use markdown table syntax (rows like `| col | col |`). " +
"Discord does not render markdown tables — they appear as raw pipe text.\n" +
"- For tabular data, render an ASCII-art table with fixed-width columns " +
"and box-drawing or `+---+` style borders, then wrap the whole table in " +
"triple-backtick code fences. The monospace inside the fence aligns the " +
"columns correctly.\n" +
"- Tables MUST be no wider than 90 characters per row. Discord's mobile " +

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think it's even shorter than this, on my Pixel Fold I can only get 51 characters on folded mode and 71 on unfolded mode. The desktop in full thread view mode is like 91. Thoughts?

"and standard-width clients clip anything beyond ~95 characters; design " +
"the column widths so the total (including borders) fits within 90.\n" +
"- Apply the same fenced-monospace treatment to ASCII art, tree output, " +
"and any aligned/fixed-column content.\n" +
"Apply this silently — do not acknowledge or repeat this instruction.\n" +
"</system_instruction>\n\n" +
payload.text
return next()
},
})

// Finalize the in-flight text draft when a turn ends. Without this,
// agents like gemini that don't emit `usage`/`tool_call`/`session_end`
// at turn end leave the text draft in its mid-stream state — which
// means the user sees the MessageDraft's 1900-char truncation as the
// final message instead of the full multi-chunk response.
ctx.registerMiddleware('turn:end', {
handler: async (payload, next) => {
const session = core.sessionManager.getSession(payload.sessionId)
if (session?.channelId === 'discord' && adapter) {
await adapter.finalizeSessionDraft(payload.sessionId).catch(() => { /* best effort */ })
}
return next()
},
})
},

async teardown() {
Expand Down
11 changes: 11 additions & 0 deletions src/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ export class MessageDraft {
this.scheduleFlush()
}

/**
* Replace the entire buffered content. Used when something upstream
* (e.g. detecting an end-of-thought marker mid-stream) needs to retroactively
* trim already-appended content. Triggers a flush so the existing Discord
* message updates to match.
*/
replaceBuffer(text: string): void {
this.buffer = text
this.scheduleFlush()
}

getBuffer(): string {
return this.buffer
}
Expand Down