Skip to content

Commit ade319d

Browse files
authored
Merge pull request #486 from Opencode-DCP/dev
fix: skip malformed messages in DCP hooks
2 parents d616c7f + cab3c45 commit ade319d

7 files changed

Lines changed: 126 additions & 19 deletions

File tree

lib/compress/search.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SessionState, WithParts } from "../state"
22
import { formatBlockRef, parseBoundaryId } from "../message-ids"
33
import { isIgnoredUserMessage } from "../messages/query"
4+
import { filterProcessableMessages } from "../messages/shape"
45
import { countAllMessageTokens } from "../token-utils"
56
import type { BoundaryReference, SearchContext, SelectionResolution } from "./types"
67

@@ -9,8 +10,7 @@ export async function fetchSessionMessages(client: any, sessionId: string): Prom
910
path: { id: sessionId },
1011
})
1112

12-
const payload = (response?.data || response) as WithParts[]
13-
return Array.isArray(payload) ? payload : []
13+
return filterProcessableMessages(response?.data || response)
1414
}
1515

1616
export function buildSearchContext(state: SessionState, rawMessages: WithParts[]): SearchContext {

lib/hooks.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
consumeCompressionStart,
2323
resolveCompressionDuration,
2424
} from "./compress/timing"
25+
import { filterProcessableMessages } from "./messages/shape"
2526
import {
2627
applyPendingManualTrigger,
2728
handleContextCommand,
@@ -103,41 +104,49 @@ export function createChatMessageTransformHandler(
103104
hostPermissions: HostPermissionSnapshot,
104105
) {
105106
return async (input: {}, output: { messages: WithParts[] }) => {
106-
await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
107+
const messages = filterProcessableMessages(output.messages)
108+
if (messages.length !== output.messages.length) {
109+
logger.warn("Skipping messages with unexpected shape during chat transform", {
110+
received: output.messages.length,
111+
usable: messages.length,
112+
})
113+
}
114+
115+
await checkSession(client, state, logger, messages, config.manualMode.enabled)
107116

108-
syncCompressPermissionState(state, config, hostPermissions, output.messages)
117+
syncCompressPermissionState(state, config, hostPermissions, messages)
109118

110119
if (state.isSubAgent && !config.experimental.allowSubAgents) {
111120
return
112121
}
113122

114123
stripHallucinations(output.messages)
115-
cacheSystemPromptTokens(state, output.messages)
116-
assignMessageRefs(state, output.messages)
117-
syncCompressionBlocks(state, logger, output.messages)
118-
syncToolCache(state, config, logger, output.messages)
119-
buildToolIdList(state, output.messages)
120-
prune(state, logger, config, output.messages)
124+
cacheSystemPromptTokens(state, messages)
125+
assignMessageRefs(state, messages)
126+
syncCompressionBlocks(state, logger, messages)
127+
syncToolCache(state, config, logger, messages)
128+
buildToolIdList(state, messages)
129+
prune(state, logger, config, messages)
121130
await injectExtendedSubAgentResults(
122131
client,
123132
state,
124133
logger,
125-
output.messages,
134+
messages,
126135
config.experimental.allowSubAgents,
127136
)
128-
const compressionPriorities = buildPriorityMap(config, state, output.messages)
137+
const compressionPriorities = buildPriorityMap(config, state, messages)
129138
prompts.reload()
130139
injectCompressNudges(
131140
state,
132141
config,
133142
logger,
134-
output.messages,
143+
messages,
135144
prompts.getRuntimePrompts(),
136145
compressionPriorities,
137146
)
138-
injectMessageIds(state, config, output.messages, compressionPriorities)
139-
applyPendingManualTrigger(state, output.messages, logger)
140-
stripStaleMetadata(output.messages)
147+
injectMessageIds(state, config, messages, compressionPriorities)
148+
applyPendingManualTrigger(state, messages, logger)
149+
stripStaleMetadata(messages)
141150

142151
if (state.sessionId) {
143152
await logger.saveContext(state.sessionId, output.messages)
@@ -165,7 +174,7 @@ export function createCommandExecuteHandler(
165174
const messagesResponse = await client.session.messages({
166175
path: { id: input.sessionID },
167176
})
168-
const messages = (messagesResponse.data || messagesResponse) as WithParts[]
177+
const messages = filterProcessableMessages(messagesResponse.data || messagesResponse)
169178

170179
await ensureSessionInitialized(
171180
client,

lib/messages/inject/subagent-results.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Logger } from "../../logger"
22
import type { SessionState, WithParts } from "../../state"
3+
import { filterProcessableMessages } from "../shape"
34
import {
45
buildSubagentResultText,
56
getSubAgentId,
@@ -12,8 +13,7 @@ async function fetchSubAgentMessages(client: any, sessionId: string): Promise<Wi
1213
path: { id: sessionId },
1314
})
1415

15-
const payload = (response?.data || response) as WithParts[]
16-
return Array.isArray(payload) ? payload : []
16+
return filterProcessableMessages(response?.data || response)
1717
}
1818

1919
export const injectExtendedSubAgentResults = async (

lib/messages/query.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { PluginConfig } from "../config"
22
import type { WithParts } from "../state"
3+
import { isMessageWithInfo } from "./shape"
34

45
export const getLastUserMessage = (
56
messages: WithParts[],
@@ -8,6 +9,9 @@ export const getLastUserMessage = (
89
const start = startIndex ?? messages.length - 1
910
for (let i = start; i >= 0; i--) {
1011
const msg = messages[i]
12+
if (!isMessageWithInfo(msg)) {
13+
continue
14+
}
1115
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
1216
return msg
1317
}
@@ -16,6 +20,10 @@ export const getLastUserMessage = (
1620
}
1721

1822
export const messageHasCompress = (message: WithParts): boolean => {
23+
if (!isMessageWithInfo(message)) {
24+
return false
25+
}
26+
1927
if (message.info.role !== "assistant") {
2028
return false
2129
}
@@ -28,6 +36,10 @@ export const messageHasCompress = (message: WithParts): boolean => {
2836
}
2937

3038
export const isIgnoredUserMessage = (message: WithParts): boolean => {
39+
if (!isMessageWithInfo(message)) {
40+
return false
41+
}
42+
3143
if (message.info.role !== "user") {
3244
return false
3345
}
@@ -47,6 +59,10 @@ export const isIgnoredUserMessage = (message: WithParts): boolean => {
4759
}
4860

4961
export function isProtectedUserMessage(config: PluginConfig, message: WithParts): boolean {
62+
if (!isMessageWithInfo(message)) {
63+
return false
64+
}
65+
5066
return (
5167
config.compress.mode === "message" &&
5268
config.compress.protectUserMessages &&

lib/messages/shape.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { WithParts } from "../state"
2+
3+
export function isMessageWithInfo(message: unknown): message is WithParts {
4+
if (!message || typeof message !== "object") {
5+
return false
6+
}
7+
8+
const info = (message as any).info
9+
const parts = (message as any).parts
10+
if (!info || typeof info !== "object") {
11+
return false
12+
}
13+
14+
return (
15+
typeof info.id === "string" &&
16+
info.id.length > 0 &&
17+
typeof info.sessionID === "string" &&
18+
info.sessionID.length > 0 &&
19+
(info.role === "user" || info.role === "assistant") &&
20+
info.time &&
21+
typeof info.time === "object" &&
22+
typeof info.time.created === "number" &&
23+
Array.isArray(parts)
24+
)
25+
}
26+
27+
export function filterProcessableMessages(messages: unknown): WithParts[] {
28+
if (!Array.isArray(messages)) {
29+
return []
30+
}
31+
32+
return messages.filter(isMessageWithInfo)
33+
}

lib/state/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import type {
66
WithParts,
77
} from "./types"
88
import { isIgnoredUserMessage, messageHasCompress } from "../messages/query"
9+
import { isMessageWithInfo } from "../messages/shape"
910
import { countTokens } from "../token-utils"
1011

1112
export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => {
13+
if (!isMessageWithInfo(msg)) {
14+
return false
15+
}
16+
1217
if (msg.info.time.created < state.lastCompaction) {
1318
return true
1419
}
@@ -58,6 +63,9 @@ export async function isSubAgentSession(client: any, sessionID: string): Promise
5863
export function findLastCompactionTimestamp(messages: WithParts[]): number {
5964
for (let i = messages.length - 1; i >= 0; i--) {
6065
const msg = messages[i]
66+
if (!isMessageWithInfo(msg)) {
67+
continue
68+
}
6169
if (msg.info.role === "assistant" && msg.info.summary === true) {
6270
return msg.info.time.created
6371
}
@@ -68,6 +76,9 @@ export function findLastCompactionTimestamp(messages: WithParts[]): number {
6876
export function countTurns(state: SessionState, messages: WithParts[]): number {
6977
let turnCount = 0
7078
for (const msg of messages) {
79+
if (!isMessageWithInfo(msg)) {
80+
continue
81+
}
7182
if (isMessageCompacted(state, msg)) {
7283
continue
7384
}

tests/hooks-permission.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,44 @@ test("chat message transform strips hallucinated tags even when compress is deni
113113
assert.equal((output.messages[0]?.parts[0] as any).text, "alpha omega")
114114
})
115115

116+
test("chat message transform ignores messages without info instead of crashing", async () => {
117+
const state = createSessionState()
118+
const logger = new Logger(false)
119+
const config = buildConfig("deny")
120+
const handler = createChatMessageTransformHandler(
121+
{ session: { get: async () => ({}) } } as any,
122+
state,
123+
logger,
124+
config,
125+
{
126+
reload() {},
127+
getRuntimePrompts() {
128+
return {} as any
129+
},
130+
} as any,
131+
{ global: undefined, agents: {} },
132+
)
133+
const output = {
134+
messages: [
135+
{
136+
role: "user",
137+
time: 1,
138+
parts: [
139+
{
140+
type: "text",
141+
text: "Carica le skill di laravel",
142+
},
143+
],
144+
} as any,
145+
],
146+
}
147+
148+
await handler({}, output as any)
149+
150+
assert.equal(state.sessionId, null)
151+
assert.equal(output.messages.length, 1)
152+
})
153+
116154
test("command execute exits after effective permission resolves to deny", async () => {
117155
let sessionMessagesCalls = 0
118156
const output = { parts: [] as any[] }

0 commit comments

Comments
 (0)